mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
user dashboard
This commit is contained in:
@@ -16,7 +16,16 @@
|
|||||||
"Bash(echo \"=== total errors: $\\(node_modules/.bin/tsc --noEmit -p apps/portal/tsconfig.json 2>&1)",
|
"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 \"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(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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,437 @@
|
|||||||
import type { ElementType } from 'react';
|
import { useState, type ElementType, type ReactNode } from 'react';
|
||||||
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
|
import {
|
||||||
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
|
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;
|
/* sample data — swap for live API results */
|
||||||
value: string;
|
/* ------------------------------------------------------------------ */
|
||||||
icon: ElementType;
|
|
||||||
|
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;
|
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 (
|
return (
|
||||||
<Paper p="md" shadow="sm" radius="md" withBorder>
|
<Paper p="lg" radius="lg" withBorder h="100%">
|
||||||
<Group justify="space-between">
|
{children}
|
||||||
<Stack gap={4}>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Title order={3}>{value}</Title>
|
|
||||||
</Stack>
|
|
||||||
<Icon size={32} color={color} />
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
function CardHeading({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap={2}>
|
||||||
<Title order={2}>Dashboard</Title>
|
<Text fw={700} fz="lg">
|
||||||
<Grid>
|
{title}
|
||||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
</Text>
|
||||||
<StatCard label="Total Items" value="—" icon={IconBox} color="#2563eb" />
|
{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's what'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>
|
||||||
<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>
|
||||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
</Grid>
|
||||||
<StatCard label="Activity" value="—" icon={IconActivity} color="#d97706" />
|
|
||||||
|
{/* 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.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export function BackofficeLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 60 }}
|
header={{ height: 64 }}
|
||||||
navbar={{ width: 240, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
navbar={{ width: 264, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||||
padding="md"
|
padding="lg"
|
||||||
|
styles={{ main: { backgroundColor: '#f5f7fb' } }}
|
||||||
>
|
>
|
||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
<AppHeader onToggle={toggle} />
|
<AppHeader onToggle={toggle} />
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
|
import {
|
||||||
import { IconLogout } from '@tabler/icons-react';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthUser } from '@tria-plc/iamui-common';
|
import { useAuthUser } from '@tria-plc/iamui-common';
|
||||||
|
|
||||||
@@ -17,16 +34,54 @@ export function AppHeader({ onToggle }: AppHeaderProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group h="100%" px="md" justify="space-between">
|
<Group h="100%" px="lg" justify="space-between">
|
||||||
<Group>
|
<Group gap="sm">
|
||||||
<Burger onClick={onToggle} size="sm" hiddenFrom="sm" />
|
<Burger onClick={onToggle} size="sm" hiddenFrom="sm" />
|
||||||
<Text fw={700} size="lg">
|
<TextInput
|
||||||
EMA Backoffice
|
visibleFrom="sm"
|
||||||
</Text>
|
w={320}
|
||||||
|
radius="md"
|
||||||
|
placeholder="Search users, roles..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<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>
|
||||||
<ActionIcon variant="subtle" onClick={handleLogout} title="Logout">
|
|
||||||
<IconLogout size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { NavLink, Stack } from '@mantine/core';
|
import { Stack, Group, Text, NavLink, ThemeIcon, Button, Box } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconDashboard,
|
IconLayoutDashboard,
|
||||||
IconBox,
|
|
||||||
IconUsers,
|
IconUsers,
|
||||||
IconClipboardList,
|
IconShieldCheck,
|
||||||
|
IconLifebuoy,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
{ label: 'Dashboard', icon: IconLayoutDashboard, path: '/dashboard' },
|
||||||
{ label: 'Items', icon: IconBox, path: '/items' },
|
|
||||||
{ label: 'User Management', icon: IconUsers, path: '/um' },
|
{ label: 'User Management', icon: IconUsers, path: '/um' },
|
||||||
{ label: 'Audit Log', icon: IconClipboardList, path: '/audit-log' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
@@ -19,16 +17,77 @@ export function AppSidebar() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="xs" gap={4}>
|
<Stack h="100%" justify="space-between" p="md" gap="lg">
|
||||||
{NAV_ITEMS.map(({ label, icon: Icon, path }) => (
|
<Stack gap="lg">
|
||||||
<NavLink
|
{/* brand */}
|
||||||
key={path}
|
<Group gap="sm" px={4} wrap="nowrap">
|
||||||
label={label}
|
<ThemeIcon size={42} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'indigo', deg: 135 }}>
|
||||||
leftSection={<Icon size={18} />}
|
<IconShieldCheck size={22} />
|
||||||
active={pathname.startsWith(path)}
|
</ThemeIcon>
|
||||||
onClick={() => navigate(path)}
|
<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={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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
RouterProvider,
|
||||||
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
} from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LoginPage,
|
LoginPage,
|
||||||
ForgotPasswordPage,
|
ForgotPasswordPage,
|
||||||
OTPVerificationPage,
|
OTPVerificationPage,
|
||||||
} from '@ema-platform/auth';
|
} from '@ema-platform/auth';
|
||||||
import { AuthLayout } from '../layouts/AuthLayout';
|
import { AuthLayout } from '../layouts/AuthLayout';
|
||||||
import { AuthProviders } from '../iam/IamProviders';
|
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||||
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
import { IamProviders } from '../iam/IamProviders';
|
||||||
|
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||||
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
|
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([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
element: <AuthLayout />,
|
element: <AuthLayout />,
|
||||||
@@ -17,7 +34,25 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/um/*', element: <UserManagementHostPage /> },
|
{
|
||||||
|
element: <ProtectedRoute />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <IamShell />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <BackofficeLayout />,
|
||||||
|
children: [
|
||||||
|
{ 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: '/404', element: <div>Page not found</div> },
|
{ path: '/404', element: <div>Page not found</div> },
|
||||||
{ path: '*', element: <Navigate to="/404" replace /> },
|
{ path: '*', element: <Navigate to="/404" replace /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
7255
backoffice-v2.pen
Normal file
7255
backoffice-v2.pen
Normal file
File diff suppressed because it is too large
Load Diff
7255
backoffice.pen
Normal file
7255
backoffice.pen
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user