merge design protal and backoffice

This commit is contained in:
mengstabketemaw
2026-06-17 13:50:20 +03:00
parent 36234a2948
commit 85e070db33
25 changed files with 2618 additions and 847 deletions

View File

@@ -1,31 +1,112 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet } from 'react-router-dom';
import { AppHeader } from './components/AppHeader';
import { AppSidebar } from './components/AppSidebar';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { logout } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import {
IconLayoutDashboard,
IconUsers,
IconUser,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
{ to: '/profile', label: 'Profile', icon: IconUser },
];
export function BackofficeLayout() {
const [opened, { toggle }] = useDisclosure();
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const [opened, { toggle: toggleNav }] = useDisclosure();
const [collapsed, setCollapsed] = useState(false);
const handleToggleCollapse = () => setCollapsed((prev) => !prev);
const handleLogout = useCallback(() => {
dispatch(logout());
navigate('/login');
}, [dispatch, navigate]);
// Breadcrumbs
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
{ label: t('nav.dashboard'), path: '/dashboard' },
...segments
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
.filter((path) => path !== '/dashboard')
.filter((path) => !path.startsWith('/um'))
.map((path) => ({ label: t('nav.dashboard'), path })),
];
const go = (item: NavItem) => {
if (item.soon) {
notify.info(`${item.label} — coming soon.`);
return;
}
if (item.to) {
navigate(item.to);
}
};
return (
<AppShell
header={{ height: 64 }}
navbar={{ width: collapsed ? 80 : 264, breakpoint: 'sm', collapsed: { mobile: !opened } }}
header={{ height: 74 }}
navbar={{
width: collapsed ? 72 : 264,
breakpoint: 'sm',
collapsed: { mobile: !opened },
}}
padding="lg"
styles={{ main: { backgroundColor: '#f5f7fb' } }}
>
<AppShell.Header>
<AppHeader onToggle={toggle} />
<AppShell.Header
style={{
background: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-gray-2)',
}}
>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={handleToggleCollapse}
navOpened={opened}
breadcrumbs={crumbs}
onNavigate={navigate}
onLogout={handleLogout}
userName={t('app.name')}
userInitials="AD"
supportedLanguages={SUPPORTED_LANGUAGES}
/>
</AppShell.Header>
<AppShell.Navbar p="md">
<AppSidebar collapsed={collapsed} onToggleCollapse={handleToggleCollapse} />
<AppShell.Navbar
p={0}
style={{
overflow: 'hidden',
transition: 'width 200ms ease',
background: 'var(--mantine-color-body)',
borderRight: '1px solid var(--mantine-color-gray-2)',
}}
>
<AppSidebar
navItems={NAV_ITEMS}
collapsed={collapsed}
activePath={location.pathname}
onToggleCollapse={handleToggleCollapse}
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
/>
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
</AppShell.Main>
</AppShell>
);

View File

@@ -1,143 +0,0 @@
import { useTranslation } from 'react-i18next';
import {
Group,
Text,
ActionIcon,
Burger,
Avatar,
Menu,
Indicator,
UnstyledButton,
Breadcrumbs,
Anchor,
rem,
} from '@mantine/core';
import {
IconLogout,
IconBell,
IconChevronDown,
IconUserCircle,
IconSettings,
IconHome,
} from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { logout } from '@ema-platform/auth';
import { ColorSchemeToggle } from './ColorSchemeToggle';
import { LanguageSwitcher } from './LanguageSwitcher';
interface AppHeaderProps {
onToggle: () => void;
}
function useBreadcrumbs() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
{ label: t('breadcrumbs.home'), path: '/dashboard', icon: <IconHome size={14} /> },
...segments
.filter((seg) => ['dashboard', 'um'].includes(seg))
.map((seg, i) => ({
label: t(`breadcrumbs.${seg}`),
path: '/' + segments.slice(0, i + 1).join('/'),
icon: null,
})),
];
return { crumbs, navigate };
}
export function AppHeader({ onToggle }: AppHeaderProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const { crumbs } = useBreadcrumbs();
const handleLogout = () => {
dispatch(logout());
navigate('/login');
};
return (
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Burger onClick={onToggle} size="sm" hiddenFrom="sm" />
<Breadcrumbs
visibleFrom="sm"
styles={{
separator: { color: 'var(--mantine-color-dimmed)', fontSize: rem(12) },
}}
>
{crumbs.map((crumb, i) =>
i < crumbs.length - 1 ? (
<Anchor
key={crumb.path}
size="sm"
c="dimmed"
onClick={() => navigate(crumb.path)}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
{crumb.icon}
{crumb.label}
</Anchor>
) : (
<Text
key={crumb.path}
size="sm"
fw={600}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
{crumb.icon}
{crumb.label}
</Text>
),
)}
</Breadcrumbs>
</Group>
<Group gap="sm" wrap="nowrap">
<LanguageSwitcher />
<ColorSchemeToggle />
<Indicator color="red" size={8} offset={6} withBorder>
<ActionIcon variant="default" size={38} radius="md" aria-label={t('common.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">
{t('common.administrator')}
</Text>
</div>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconUserCircle size={16} />}>{t('common.profile')}</Menu.Item>
<Menu.Item leftSection={<IconSettings size={16} />}>{t('common.settings')}</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconLogout size={16} />} onClick={handleLogout}>
{t('common.logout')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}

View File

@@ -1,116 +0,0 @@
import { useTranslation } from 'react-i18next';
import {
AppShell,
Group,
NavLink,
ScrollArea,
Stack,
Text,
ThemeIcon,
ActionIcon,
rem,
} from '@mantine/core';
import {
IconLayoutDashboard,
IconUsers,
IconShieldCheck,
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
interface AppSidebarProps {
collapsed: boolean;
onToggleCollapse: () => void;
}
export function AppSidebar({ collapsed, onToggleCollapse }: AppSidebarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
return (
<>
<AppShell.Section pb="md">
<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>
{!collapsed && (
<div>
<Text fw={700} lh={1.1}>
{t('app.name')}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{t('app.tagline')}
</Text>
</div>
)}
</Group>
</AppShell.Section>
<AppShell.Section grow component={ScrollArea}>
<Stack gap={4}>
{!collapsed && (
<Text size="xs" fw={700} c="dimmed" px="xs" mb={4} style={{ letterSpacing: 1 }}>
{t('nav.menu')}
</Text>
)}
<NavLink
key="dashboard"
label={collapsed ? '' : t('nav.dashboard')}
leftSection={<IconLayoutDashboard size={19} stroke={1.6} />}
active={pathname.startsWith('/dashboard')}
onClick={() => navigate('/dashboard')}
variant="light"
color="blue"
styles={{
root: { borderRadius: rem(10) },
label: { fontWeight: 500, display: collapsed ? 'none' : 'block' },
}}
/>
<NavLink
key="um"
label={collapsed ? '' : t('nav.userManagement')}
leftSection={<IconUsers size={19} stroke={1.6} />}
active={pathname.startsWith('/um')}
onClick={() => navigate('/um/user-management/dashboard')}
variant="light"
color="blue"
styles={{
root: { borderRadius: rem(10) },
label: { fontWeight: 500, display: collapsed ? 'none' : 'block' },
}}
/>
</Stack>
</AppShell.Section>
<AppShell.Section>
<Group
gap="xs"
wrap="nowrap"
p="xs"
style={{
borderRadius: rem(12),
cursor: 'pointer',
}}
onClick={onToggleCollapse}
>
{!collapsed && (
<Text size="sm" fw={500} style={{ flex: 1 }}>
{t('common.collapse')}
</Text>
)}
<ActionIcon
variant="subtle"
color="gray"
aria-label={collapsed ? t('common.expand') : t('common.collapse')}
style={{ marginLeft: collapsed ? 'auto' : undefined }}
>
{collapsed ? <IconChevronRight size={18} /> : <IconChevronLeft size={18} />}
</ActionIcon>
</Group>
</AppShell.Section>
</>
);
}

View File

@@ -1,20 +0,0 @@
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
export function ColorSchemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computed === 'dark';
return (
<ActionIcon
variant="default"
size={38}
radius="md"
aria-label="Toggle color scheme"
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
>
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
</ActionIcon>
);
}

View File

@@ -1,30 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Menu, ActionIcon } from '@mantine/core';
import { IconWorld, IconCheck } from '@tabler/icons-react';
import { i18n, SUPPORTED_LANGUAGES } from '../../i18n/config';
export function LanguageSwitcher() {
const { t } = useTranslation();
return (
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="default" size={38} radius="md" aria-label={t('language.label')}>
<IconWorld size={19} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('language.label')}</Menu.Label>
{SUPPORTED_LANGUAGES.map((code) => (
<Menu.Item
key={code}
onClick={() => i18n.changeLanguage(code)}
rightSection={i18n.language === code ? <IconCheck size={16} /> : undefined}
>
{t(`language.${code}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}