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

@@ -0,0 +1,124 @@
import { useState } from 'react';
import { ActionIcon, Button, Popover, TextInput } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
import { IconCalendarEvent } from '@tabler/icons-react';
import { EthDateTime } from 'ethiopian-calendar-date-converter';
import '@daypicker/react/dist/style.css';
const EC_MONTHS_AM = [
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ',
];
function toAmharicDisplay(date: Date): string {
try {
const eth = EthDateTime.fromEuropeanDate(date);
return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`;
} catch {
return date.toLocaleDateString('en-US');
}
}
export function toEthiopicDateLabel(date: Date): string {
try {
const eth = EthDateTime.fromEuropeanDate(date);
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
} catch {
return date.toLocaleDateString('en-US');
}
}
export interface AmharicDatePickerProps {
label?: string;
value?: Date | null;
onChange?: (date: Date | null) => void;
required?: boolean;
placeholder?: string;
}
export function AmharicDatePicker({
label,
value,
onChange,
required,
placeholder,
}: AmharicDatePickerProps) {
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>('AMH');
const [opened, { close, toggle }] = useDisclosure(false);
const displayValue = value
? calendarType === 'EN'
? value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: toAmharicDisplay(value)
: '';
return (
<Popover
opened={opened}
onChange={close}
position="bottom"
width="auto"
trapFocus
withArrow
>
<Popover.Target>
<TextInput
label={label}
required={required}
value={displayValue}
readOnly
placeholder={placeholder}
onClick={toggle}
leftSection={
<Button
variant="light"
size="compact-xs"
onClick={(e) => {
e.stopPropagation();
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
}}
aria-label="Switch calendar type"
>
{calendarType}
</Button>
}
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
rightSection={
<ActionIcon size="md" variant="transparent" onClick={toggle}>
<IconCalendarEvent size={20} />
</ActionIcon>
}
/>
</Popover.Target>
<Popover.Dropdown p="md">
{calendarType === 'AMH' ? (
<EthiopicDayPicker
mode="single"
selected={value ?? undefined}
numerals="latn"
onSelect={(date: Date | undefined) => {
onChange?.(date ?? null);
close();
}}
/>
) : (
<GregorianDayPicker
mode="single"
selected={value ?? undefined}
onSelect={(date: Date | undefined) => {
onChange?.(date ?? null);
close();
}}
/>
)}
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -1,45 +0,0 @@
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export function ColorSchemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computed === 'dark';
return (
<UnstyledButton
aria-label={t('common.toggleTheme')}
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
</UnstyledButton>
);
}

View File

@@ -1,83 +0,0 @@
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../i18n/config';
interface LanguageSwitcherProps {
/** `icon` renders a compact globe button; `button` shows the language label. */
variant?: 'icon' | 'button';
}
export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
const { t, i18n } = useTranslation();
const current = i18n.language as AppLanguage;
const change = (lng: AppLanguage) => {
if (lng !== current) i18n.changeLanguage(lng);
};
return (
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
<Menu.Target>
<UnstyledButton
aria-label={t('language.label')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: rem(4),
width: variant === 'icon' ? rem(38) : 'auto',
height: rem(38),
padding: variant === 'icon' ? 0 : '0 12px',
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
<IconWorld size={19} />
{variant !== 'icon' && (
<>
<Text size="sm" fw={500}>
{t(`language.${current}`)}
</Text>
<IconChevronDown size={14} />
</>
)}
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown
style={{ borderRadius: rem(12), padding: rem(6) }}
>
<Menu.Label>{t('language.label')}</Menu.Label>
{SUPPORTED_LANGUAGES.map((lng) => (
<Menu.Item
key={lng}
onClick={() => change(lng)}
rightSection={
current === lng ? <IconCheck size={16} /> : undefined
}
style={{ borderRadius: rem(8) }}
>
{t(`language.${lng}`)}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -1,25 +0,0 @@
import { Group, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
/** Right-aligned actions (e.g. a primary button). */
action?: ReactNode;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Stack gap={2}>
<Title order={2}>{title}</Title>
{subtitle && (
<Text c="dimmed" size="sm">
{subtitle}
</Text>
)}
</Stack>
{action}
</Group>
);
}

View File

@@ -1,30 +0,0 @@
import { Box, useMantineTheme } from '@mantine/core';
export function BrandAvatar({
initials = 'AB',
size = 38,
}: {
initials?: string;
size?: number;
}) {
const theme = useMantineTheme();
return (
<Box
w={size}
h={size}
style={{
borderRadius: '50%',
background: theme.other.heroGradient as string,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.36,
fontWeight: 700,
flexShrink: 0,
}}
>
{initials}
</Box>
);
}

View File

@@ -40,13 +40,12 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { notify, PageHeader } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { PageHeader } from '../../../components/PageHeader';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '../../auth/types/auth.types';
import type { AuthUser } from '@ema-platform/auth';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {

View File

@@ -36,6 +36,7 @@ import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
// ---------------------------------------------------------------------------
// Dummy API
@@ -267,7 +268,7 @@ export function SeafarerRegistrationPage() {
const [middleName, setMiddleName] = useState<BilingualValue>({ en: '', am: '' });
const [lastName, setLastName] = useState<BilingualValue>({ en: '', am: '' });
const [gender, setGender] = useState<string | null>(null);
const [dob, setDob] = useState('');
const [dob, setDob] = useState<Date | null>(null);
const [placeOfBirth, setPlaceOfBirth] = useState('');
const [nationality, setNationality] = useState<string | null>('Ethiopian');
const [maritalStatus, setMaritalStatus] = useState<string | null>(null);
@@ -311,7 +312,7 @@ export function SeafarerRegistrationPage() {
setSubmitting(true);
try {
const result = await submitSeafarerRegistration({
personalInfo: { firstName, middleName, lastName, gender, dob, placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
personalInfo: { firstName, middleName, lastName, gender, dob: dob?.toISOString().split('T')[0] ?? '', placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
contactDetails: { mobile, email, region, city, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
documents: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v?.name ?? null])),
});
@@ -359,7 +360,7 @@ export function SeafarerRegistrationPage() {
<BilingualInput label="Middle Name" value={middleName} onChange={setMiddleName} />
<BilingualInput label="Last Name" required value={lastName} onChange={setLastName} />
<Select label="Gender" placeholder="Select" required data={GENDERS} value={gender} onChange={setGender} />
<TextInput label="Date of Birth" type="date" required value={dob} onChange={(e) => setDob(e.currentTarget.value)} />
<AmharicDatePicker label="Date of Birth" required value={dob} onChange={setDob} />
<TextInput label="Place of Birth" placeholder="City, Region" required value={placeOfBirth} onChange={(e) => setPlaceOfBirth(e.currentTarget.value)} />
<Select label="Nationality" required data={NATIONALITIES} value={nationality} onChange={setNationality} searchable />
<Select label="Marital Status" placeholder="Select" data={MARITAL_STATUSES} value={maritalStatus} onChange={setMaritalStatus} />
@@ -455,7 +456,7 @@ export function SeafarerRegistrationPage() {
<ReviewRow label="Middle Name" value={`${middleName.en}${middleName.am ? ` / ${middleName.am}` : ''}`} />
<ReviewRow label="Last Name" value={`${lastName.en}${lastName.am ? ` / ${lastName.am}` : ''}`} />
<ReviewRow label="Gender" value={gender ?? ''} />
<ReviewRow label="Date of Birth" value={dob} />
<ReviewRow label="Date of Birth" value={`${dob?.toLocaleDateString('en-US') ?? ''}${dob ? ` (${toEthiopicDateLabel(dob)})` : ''}`} />
<ReviewRow label="Place of Birth" value={placeOfBirth} />
<ReviewRow label="Nationality" value={nationality ?? ''} />
<ReviewRow label="Marital Status" value={maritalStatus ?? ''} />

View File

@@ -16,7 +16,7 @@ import {
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../../components/PageHeader';
import { PageHeader } from '@ema-platform/ui';
const FAQ_KEYS = ['1', '2', '3', '4'] as const;

View File

@@ -1,68 +1,31 @@
import {
Anchor,
AppShell,
Burger,
Group,
Indicator,
Menu,
NavLink,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
rem,
} from '@mantine/core';
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconBell,
IconChevronLeft,
IconChevronRight,
IconLayoutDashboard,
IconLifebuoy,
IconList,
IconLogout,
IconUser,
IconUserCircle,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { notify } from '@ema-platform/ui';
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import { logout } from '@ema-platform/auth';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
import { BrandMark } from '@ema-platform/auth';
import { BrandAvatar } from '../components/ui';
interface NavItem {
label: string;
icon: Icon;
to?: string;
soon?: boolean;
}
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppSelector } from '../store/hooks';
const NAV_ITEMS: (NavItem & { i18nKey: string })[] = [
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/seafarer-registry', label: 'Seafarer Registry', i18nKey: 'nav.seafarerRegistry', icon: IconList },
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUser },
{ to: '/support', label: 'Support', i18nKey: 'nav.support', icon: IconLifebuoy },
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconLifebuoy },
];
const PAGE_META: Record<string, { i18nKey: string; subtitleKey: string }> = {
'/dashboard': {
i18nKey: 'nav.dashboard',
subtitleKey: 'dashboard.title',
},
'/seafarer-registry': {
i18nKey: 'nav.seafarerRegistry',
subtitleKey: '',
},
'/profile': {
i18nKey: 'nav.profile',
subtitleKey: '',
},
const PAGE_META: Record<string, { i18nKey: string }> = {
'/dashboard': { i18nKey: 'nav.dashboard' },
'/seafarer-registry': { i18nKey: 'nav.seafarerRegistry' },
'/profile': { i18nKey: 'nav.profile' },
};
export function PortalLayout() {
@@ -70,10 +33,11 @@ export function PortalLayout() {
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const user = useAppSelector((state) => state.auth.user);
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
// Breadcrumb trail: always rooted at "Home", then any matched page segments.
// Breadcrumb trail
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
{ label: t('nav.dashboard'), path: '/dashboard' },
@@ -99,12 +63,10 @@ export function PortalLayout() {
navigate('/login');
};
const activeNavItem = (item: NavItem) =>
!!item.to &&
(location.pathname === item.to ||
location.pathname.startsWith(`${item.to}/`));
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
: '?';
return (
<AppShell
@@ -116,237 +78,25 @@ export function PortalLayout() {
}}
padding="lg"
>
{/* ---- Header ---------------------------------------------------- */}
<AppShell.Header
style={{
background: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-gray-2)',
}}
>
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{/* Hamburger — styled like user-management Top.tsx */}
<UnstyledButton
onClick={isMobile ? toggleNav : toggleSidebar}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
<Burger
opened={navOpened}
onClick={() => {}}
size="sm"
aria-label="Toggle navigation"
styles={{
root: { border: 'none', background: 'transparent' },
burger: { '--burger-color': 'currentColor' },
}}
/>
</UnstyledButton>
{/* Breadcrumbs — card container with pill-style crumbs matching DynamicBreadcrumb */}
<Group
gap={2}
wrap="nowrap"
visibleFrom="xs"
style={{
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
padding: '4px 10px',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden',
}}
>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
{i > 0 && (
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
)}
{isLast ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: rem(999),
padding: '2px 12px',
fontSize: rem(12),
fontWeight: 600,
background: 'var(--mantine-color-primary-light)',
color: 'var(--mantine-color-primary-7)',
boxShadow: '0 0 0 1px var(--mantine-color-primary-2)',
whiteSpace: 'nowrap',
lineHeight: '22px',
}}
>
{crumb.label}
</span>
) : (
<Anchor
size="sm"
c="gray.6"
onClick={() => {
navigate(crumb.path);
closeNav();
}}
style={{
cursor: 'pointer',
borderRadius: rem(999),
padding: '2px 10px',
fontSize: rem(12),
fontWeight: 500,
whiteSpace: 'nowrap',
textDecoration: 'none',
lineHeight: '22px',
transition: 'all 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
{crumb.label}
</Anchor>
)}
</div>
);
})}
</Group>
</Group>
<Group gap="sm" wrap="nowrap">
<LanguageSwitcher />
<ColorSchemeToggle />
{/* Notification bell — styled like UM action buttons */}
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
aria-label="Notifications"
>
<Indicator color="red" size={9} offset={5} withBorder>
<IconBell size={19} />
</Indicator>
</UnstyledButton>
{/* Profile avatar menu */}
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
<Menu.Target>
<UnstyledButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<BrandAvatar size={38} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown
style={{
borderRadius: rem(12),
padding: rem(6),
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
}}
>
<Menu.Label
style={{
padding: rem(10),
fontWeight: 600,
fontSize: rem(13),
}}
>
Abebe Bekele
</Menu.Label>
<Menu.Item
leftSection={<IconUserCircle size={16} />}
onClick={() => navigate('/profile')}
style={{ borderRadius: rem(8) }}
>
Profile
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconLogout size={16} />}
onClick={handleLogout}
style={{ borderRadius: rem(8) }}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={toggleSidebar}
navOpened={navOpened}
breadcrumbs={crumbs}
onNavigate={navigate}
onLogout={handleLogout}
userName={displayName || t('app.name')}
userInitials={initials}
supportedLanguages={SUPPORTED_LANGUAGES}
/>
</AppShell.Header>
{/* ---- Sidebar -------------------------------------------------- */}
<AppShell.Navbar
p={0}
style={{
@@ -356,149 +106,15 @@ export function PortalLayout() {
borderRight: '1px solid var(--mantine-color-gray-2)',
}}
>
{/* Brand header — matching AppSidebar Brand section */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(12),
height: 74,
padding: sidebarCollapsed ? '0 12px' : '0 20px',
borderBottom: '1px solid var(--mantine-color-gray-2)',
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
flexShrink: 0,
}}
>
<BrandMark size={32} />
{!sidebarCollapsed && (
<div style={{ minWidth: 0 }}>
<Text
fw={800}
size="xs"
style={{
textTransform: 'uppercase',
letterSpacing: '0.08em',
lineHeight: 1.2,
color: 'var(--mantine-color-primary-7)',
}}
>
{t('app.name')}
</Text>
<Text
size="xs"
c="dimmed"
style={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: rem(10),
lineHeight: 1.3,
}}
>
{t('app.authority')}
</Text>
</div>
)}
</div>
{/* Navigation items — original Mantine NavLink style */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={4}>
{NAV_ITEMS.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (sidebarCollapsed) {
const label = t(item.i18nKey);
return (
<Tooltip key={item.label} label={label} position="right" withArrow>
<UnstyledButton
onClick={() => go(item)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
color: active ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
</UnstyledButton>
</Tooltip>
);
}
const label = t(item.i18nKey);
return (
<NavLink
key={item.label}
active={active}
label={label}
leftSection={<ItemIcon size={19} stroke={1.6} />}
onClick={() => go(item)}
variant="light"
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
/>
);
})}
</Stack>
</AppShell.Section>
{/* Collapse toggle — matching AppSidebar collapse button */}
<div
style={{
padding: rem(8),
borderTop: '1px solid var(--mantine-color-gray-2)',
flexShrink: 0,
}}
>
<Tooltip
label={sidebarCollapsed ? t('nav.expandSidebar', 'Expand sidebar') : t('nav.collapseSidebar', 'Collapse sidebar')}
position="right"
withArrow
disabled={!sidebarCollapsed}
>
<UnstyledButton
onClick={toggleSidebar}
visibleFrom="sm"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
gap: rem(10),
width: '100%',
padding: '10px 12px',
borderRadius: rem(12),
color: 'var(--mantine-color-gray-5)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
fontSize: rem(13),
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-5)';
}}
>
{sidebarCollapsed ? (
<IconChevronRight size={18} stroke={1.6} />
) : (
<>
<IconChevronLeft size={18} stroke={1.6} />
<span>{t('nav.collapseSidebar', 'Collapse')}</span>
</>
)}
</UnstyledButton>
</Tooltip>
</div>
<AppSidebar
navItems={NAV_ITEMS.map(({ i18nKey, ...rest }) => ({ ...rest, label: t(i18nKey) }))}
collapsed={sidebarCollapsed}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
/>
</AppShell.Navbar>
<AppShell.Main>