mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
merge design protal and backoffice
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/* Segmented "pill" tab bar — light gray track with a white active pill. */
|
||||
.list {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
background: var(--mantine-color-gray-1);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 18px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-gray-7);
|
||||
background: transparent;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-gray-9);
|
||||
}
|
||||
|
||||
.tab[data-active],
|
||||
.tab[data-active]:hover {
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-emaPrimary-7);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
}
|
||||
|
||||
/* Selectable option card (language + appearance). */
|
||||
.choice {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.choice:hover {
|
||||
border-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
.choiceActive,
|
||||
.choiceActive:hover {
|
||||
border-color: var(--mantine-color-emaPrimary-6);
|
||||
background: var(--mantine-color-emaPrimary-0);
|
||||
}
|
||||
605
apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx
Normal file
605
apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
type MantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAt,
|
||||
IconBell,
|
||||
IconCheck,
|
||||
IconCircle,
|
||||
IconCircleCheckFilled,
|
||||
IconDeviceDesktop,
|
||||
IconDeviceFloppy,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconMoon,
|
||||
IconPhone,
|
||||
IconSettings,
|
||||
IconShieldLock,
|
||||
IconSun,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
function getInitials(name: string, fallback: string) {
|
||||
const source = name?.trim() || fallback?.trim() || '';
|
||||
if (!source) return '?';
|
||||
const parts = source.split(/\s+/);
|
||||
const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : source.slice(0, 2);
|
||||
return letters.toUpperCase();
|
||||
}
|
||||
|
||||
/** 0–4 rough strength score used by the meter on the security tab. */
|
||||
function passwordScore(pw: string) {
|
||||
if (!pw) return 0;
|
||||
let score = 0;
|
||||
if (pw.length >= 8) score++;
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
||||
if (/\d/.test(pw)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [passwordTrigger] = useApiMutation<unknown>();
|
||||
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
|
||||
// UI-only preferences (no backend wiring yet).
|
||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
|
||||
// Load the latest profile from the server on mount so the form always
|
||||
// reflects the current account information (the cached user may be stale).
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
meTrigger({ url: '/auth/me', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((me) => {
|
||||
if (active) dispatch(setUser(me));
|
||||
})
|
||||
.catch(() => {
|
||||
/* fall back to the cached user already in the store */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// meTrigger/dispatch are stable; run once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ---- Profile form ----
|
||||
const profileSchema = z.object({
|
||||
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
username: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.phoneRequired') }),
|
||||
});
|
||||
type ProfileValues = z.infer<typeof profileSchema>;
|
||||
|
||||
const {
|
||||
register: registerProfile,
|
||||
handleSubmit: handleProfileSubmit,
|
||||
reset: resetProfile,
|
||||
formState: { errors: profileErrors },
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
values: {
|
||||
nameEn: user?.name?.en ?? '',
|
||||
nameAm: user?.name?.am ?? '',
|
||||
username: user?.username ?? '',
|
||||
email: user?.email ?? '',
|
||||
phoneNumber: user?.phoneNumber ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSaveProfile = async (values: ProfileValues) => {
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
await updateTrigger({
|
||||
url: '/auth/update-profile',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
phoneNumber: values.phoneNumber,
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
notify.success(t('profile.profileUpdated'));
|
||||
} catch {
|
||||
notify.error(t('profile.updateFailed'));
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Password form ----
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t('profile.validation.passwordMismatch'),
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
type PasswordValues = z.infer<typeof passwordSchema>;
|
||||
|
||||
const {
|
||||
register: registerPassword,
|
||||
handleSubmit: handlePasswordSubmit,
|
||||
reset: resetPassword,
|
||||
watch: watchPassword,
|
||||
formState: { errors: passwordErrors },
|
||||
} = useForm<PasswordValues>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
const onChangePassword = async (values: PasswordValues) => {
|
||||
setIsSavingPassword(true);
|
||||
try {
|
||||
await passwordTrigger({
|
||||
url: '/auth/change-password',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
confirmPassword: values.confirmPassword,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
notify.success(t('profile.passwordChanged'));
|
||||
resetPassword();
|
||||
} catch {
|
||||
notify.error(t('profile.passwordFailed'));
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const score = passwordScore(watchPassword('newPassword'));
|
||||
const strengthLabels = [
|
||||
'',
|
||||
t('profile.strength.weak'),
|
||||
t('profile.strength.fair'),
|
||||
t('profile.strength.good'),
|
||||
t('profile.strength.strong'),
|
||||
];
|
||||
const strengthColors = ['gray', 'red', 'orange', 'emaPrimary', 'emaTeal'];
|
||||
|
||||
const flags: Record<AppLanguage, string> = { en: '\uD83C\uDDEC\uD83C\uDDE7', am: '\uD83C\uDDEA\uD83C\uDDF9' };
|
||||
const appearanceOptions: {
|
||||
value: MantineColorScheme;
|
||||
label: string;
|
||||
icon: typeof IconSun;
|
||||
}[] = [
|
||||
{ value: 'light', label: t('profile.appearance.light'), icon: IconSun },
|
||||
{ value: 'dark', label: t('profile.appearance.dark'), icon: IconMoon },
|
||||
{ value: 'auto', label: t('profile.appearance.system'), icon: IconDeviceDesktop },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={900}>
|
||||
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
|
||||
|
||||
{/* Profile summary */}
|
||||
<Paper p="lg" shadow="sm" radius="lg" withBorder>
|
||||
<Group align="center" wrap="nowrap">
|
||||
<Box
|
||||
w={64}
|
||||
h={64}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundImage: 'linear-gradient(135deg, #3b6ccc 0%, #1fc29d 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="xl" c="white">
|
||||
{getInitials(displayName, user?.email ?? '')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Title order={4}>{displayName || '\u2014'}</Title>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={user?.isPhoneNumberVerified ? 'emaTeal' : 'gray'}
|
||||
size="sm"
|
||||
>
|
||||
{user?.isPhoneNumberVerified
|
||||
? t('profile.verified')
|
||||
: t('profile.unverified')}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={6} mt={2} c="dimmed">
|
||||
<IconMail size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{user?.email}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Box style={{ flex: 1 }} />
|
||||
|
||||
{user?.username && (
|
||||
<Badge
|
||||
visibleFrom="xs"
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
leftSection={<IconAt size={13} />}
|
||||
>
|
||||
{user.username}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
defaultValue="profile"
|
||||
variant="pills"
|
||||
classNames={{ list: classes.list, tab: classes.tab }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="profile" leftSection={<IconUserCircle size={18} />}>
|
||||
{t('profile.tabs.profile')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
|
||||
{t('profile.tabs.security')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="preferences" leftSection={<IconSettings size={18} />}>
|
||||
{t('profile.tabs.preferences')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ---- Profile ---- */}
|
||||
<Tabs.Panel value="profile" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handleProfileSubmit(onSaveProfile)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.personal')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t('profile.personalHint')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={t('profile.fields.fullNameEn')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={profileErrors.nameEn?.message}
|
||||
{...registerProfile('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.fullNameAm')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={profileErrors.nameAm?.message}
|
||||
{...registerProfile('nameAm')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.username')}
|
||||
description={t('profile.fields.usernameHint')}
|
||||
readOnly
|
||||
variant="filled"
|
||||
leftSection={<IconAt size={18} />}
|
||||
error={profileErrors.username?.message}
|
||||
{...registerProfile('username')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="md">
|
||||
{t('profile.contact')}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={t('profile.fields.email')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={profileErrors.email?.message}
|
||||
{...registerProfile('email')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('profile.fields.phone')}
|
||||
leftSection={<IconPhone size={18} />}
|
||||
error={profileErrors.phoneNumber?.message}
|
||||
{...registerProfile('phoneNumber')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => resetProfile()}
|
||||
>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSavingProfile}
|
||||
leftSection={<IconDeviceFloppy size={18} />}
|
||||
>
|
||||
{t('profile.updateProfile')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.security')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t('profile.securityHint')}
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<PasswordInput
|
||||
maw={360}
|
||||
label={t('profile.fields.currentPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.oldPassword?.message}
|
||||
{...registerPassword('oldPassword')}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.confirmPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.confirmPassword?.message}
|
||||
{...registerPassword('confirmPassword')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{score > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{t('profile.strength.label')}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} c={strengthColors[score]}>
|
||||
{strengthLabels[score]}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} grow>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
h={6}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor:
|
||||
i <= score
|
||||
? `var(--mantine-color-${strengthColors[score]}-6)`
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={600}>{t('profile.twoStep.title')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.twoStep.desc')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoStepEnabled}
|
||||
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSavingPassword}
|
||||
leftSection={<IconShieldLock size={18} />}
|
||||
>
|
||||
{t('profile.updatePassword')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Preferences ---- */}
|
||||
<Tabs.Panel value="preferences" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.languageTitle')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t('profile.languageHint')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{SUPPORTED_LANGUAGES.map((lng) => {
|
||||
const active = i18n.language === lng;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={lng}
|
||||
onClick={() => i18n.changeLanguage(lng)}
|
||||
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
||||
p="md"
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<Text fz={22}>{flags[lng as AppLanguage]}</Text>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t(`language.${lng}`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{lng === 'en' ? 'English (United States)' : 'Amharic'}
|
||||
</Text>
|
||||
</div>
|
||||
{active ? (
|
||||
<IconCircleCheckFilled
|
||||
size={20}
|
||||
color="var(--mantine-color-emaPrimary-6)"
|
||||
/>
|
||||
) : (
|
||||
<IconCircle
|
||||
size={20}
|
||||
color="var(--mantine-color-gray-4)"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Title order={5}>{t('profile.appearance.title')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t('profile.appearance.subtitle')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
{appearanceOptions.map(({ value, label, icon: Icon }) => {
|
||||
const active = colorScheme === value;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={value}
|
||||
onClick={() => setColorScheme(value)}
|
||||
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
|
||||
p="md"
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<Icon
|
||||
size={20}
|
||||
color={
|
||||
active
|
||||
? 'var(--mantine-color-emaPrimary-6)'
|
||||
: 'var(--mantine-color-gray-6)'
|
||||
}
|
||||
/>
|
||||
<Text fw={600} size="sm" style={{ flex: 1 }}>
|
||||
{label}
|
||||
</Text>
|
||||
{active && (
|
||||
<IconCircleCheckFilled
|
||||
size={18}
|
||||
color="var(--mantine-color-emaPrimary-6)"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group align="flex-start" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IconBell size={20} color="var(--mantine-color-gray-6)" />
|
||||
<div>
|
||||
<Text fw={600}>{t('profile.notifications.title')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.notifications.desc')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onChange={(e) => setEmailNotifications(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconCheck size={18} />}
|
||||
onClick={() => notify.success(t('profile.profileUpdated'))}
|
||||
>
|
||||
{t('profile.savePreferences')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ export const am: Translations = {
|
||||
menu: 'ምናሌ',
|
||||
dashboard: 'ዳሽቦርድ',
|
||||
userManagement: 'የተጠቃሚ አስተዳደር',
|
||||
profile: 'መገለጫ',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
expandSidebar: 'ዘርጋ',
|
||||
},
|
||||
|
||||
common: {
|
||||
@@ -31,6 +34,7 @@ export const am: Translations = {
|
||||
administrator: 'አስተዳዳሪ',
|
||||
collapse: 'ሰብስብ',
|
||||
expand: 'ዘርጋ',
|
||||
toggleTheme: 'የብርሃን / ጨለማ ሁነታን ይቀይሩ',
|
||||
},
|
||||
|
||||
breadcrumbs: {
|
||||
@@ -81,4 +85,77 @@ export const am: Translations = {
|
||||
thisMonth: 'በዚህ ወር',
|
||||
},
|
||||
},
|
||||
|
||||
profile: {
|
||||
title: 'የእኔ መገለጫ',
|
||||
subtitle: 'የመለያ ዝርዝሮችዎን እና ምርጫዎችዎን ያስተዳድሩ።',
|
||||
personal: 'የግል መረጃ',
|
||||
contact: 'መገኛ',
|
||||
preferences: 'ምርጫዎች',
|
||||
security: 'ደህንነት',
|
||||
securityHint: 'በሌላ ቦታ የማይጠቀሙትን ጠንካራ የይለፍ ቃል ይምረጡ።',
|
||||
changePassword: 'የይለፍ ቃል ይቀይሩ',
|
||||
updatePassword: 'የይለፍ ቃል አዘምን',
|
||||
updateProfile: 'ለውጦችን አስቀምጥ',
|
||||
savePreferences: 'ምርጫዎችን አስቀምጥ',
|
||||
cancel: 'ሰርዝ',
|
||||
verified: 'የተረጋገጠ',
|
||||
unverified: 'ያልተረጋገጠ',
|
||||
tabs: {
|
||||
profile: 'መገለጫ',
|
||||
security: 'ደህንነት',
|
||||
preferences: 'ምርጫዎች',
|
||||
},
|
||||
personalHint: 'በኦፊሴላዊ ኢማ ሰነዶች ላይ እንደሚታየው ስምዎ።',
|
||||
languageTitle: 'ቋንቋ',
|
||||
languageHint: 'በአስተዳደር ፓነል ውስጥ የሚጠቀሙትን ቋንቋ ይምረጡ።',
|
||||
appearance: {
|
||||
title: 'መልክ',
|
||||
subtitle: 'የአስተዳደር ፓነል በመሣሪያዎ ላይ እንዴት እንደሚታይ ይምረጡ።',
|
||||
light: 'የቀን',
|
||||
dark: 'ሌሊት',
|
||||
system: 'ሲስተም',
|
||||
},
|
||||
twoStep: {
|
||||
title: 'ባለሁለት ደረጃ ማረጋገጫ',
|
||||
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
|
||||
},
|
||||
notifications: {
|
||||
title: 'የኢሜይል ማሳወቂያዎች',
|
||||
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',
|
||||
},
|
||||
strength: {
|
||||
label: 'የይለፍ ቃል ጥንካሬ',
|
||||
weak: 'ደካማ',
|
||||
fair: 'መካከለኛ',
|
||||
good: 'ጥሩ',
|
||||
strong: 'ጠንካራ',
|
||||
},
|
||||
profileUpdated: 'መገለጫ በተሳካ ሁኔታ ዘምኗል',
|
||||
passwordChanged: 'የይለፍ ቃል በተሳካ ሁኔታ ተቀይሯል',
|
||||
updateFailed: 'መገለጫውን ማዘመን አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
|
||||
passwordFailed: 'የይለፍ ቃሉን መቀየር አልተቻለም። የአሁኑን የይለፍ ቃል ያረጋግጡ እና እንደገና ይሞክሩ።',
|
||||
fields: {
|
||||
fullNameEn: 'ሙሉ ስም (እንግሊዝኛ)',
|
||||
fullNameAm: 'ሙሉ ስም (አማርኛ)',
|
||||
username: 'የተጠቃሚ ስም',
|
||||
usernameHint: 'የተጠቃሚ ስም መቀየር አይቻልም',
|
||||
organization: 'ድርጅት',
|
||||
email: 'የኢሜይል አድራሻ',
|
||||
phone: 'ስልክ ቁጥር',
|
||||
address: 'አድራሻ',
|
||||
language: 'የሚመረጥ ቋንቋ',
|
||||
currentPassword: 'የአሁኑ የይለፍ ቃል',
|
||||
newPassword: 'አዲስ የይለፍ ቃል',
|
||||
confirmPassword: 'አዲስ የይለፍ ቃል ያረጋግጡ',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: 'ስም ያስፈልጋል',
|
||||
emailInvalid: 'የሚሰራ ኢሜይል ያስገቡ',
|
||||
usernameRequired: 'የተጠቃሚ ስም ያስፈልጋል',
|
||||
phoneRequired: 'ስልክ ቁጥር ያስፈልጋል',
|
||||
passwordMin: 'የይለፍ ቃል ቢያንስ 8 ቁምፊዎች መሆን አለበት',
|
||||
passwordMismatch: 'የይለፍ ቃላት አይዛመዱም',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ export const en = {
|
||||
menu: 'MENU',
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
profile: 'Profile',
|
||||
collapseSidebar: 'Collapse',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
},
|
||||
|
||||
common: {
|
||||
@@ -29,6 +32,7 @@ export const en = {
|
||||
administrator: 'Administrator',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
toggleTheme: 'Toggle light / dark mode',
|
||||
},
|
||||
|
||||
breadcrumbs: {
|
||||
@@ -79,6 +83,80 @@ export const en = {
|
||||
thisMonth: 'This Month',
|
||||
},
|
||||
},
|
||||
|
||||
profile: {
|
||||
title: 'My Profile',
|
||||
subtitle: 'Manage your account details and preferences.',
|
||||
personal: 'Personal information',
|
||||
contact: 'Contact',
|
||||
preferences: 'Preferences',
|
||||
security: 'Security',
|
||||
securityHint: 'Choose a strong password you do not use anywhere else.',
|
||||
changePassword: 'Change password',
|
||||
updatePassword: 'Update password',
|
||||
updateProfile: 'Save changes',
|
||||
savePreferences: 'Save preferences',
|
||||
cancel: 'Cancel',
|
||||
verified: 'Verified',
|
||||
unverified: 'Unverified',
|
||||
tabs: {
|
||||
profile: 'Profile',
|
||||
security: 'Security',
|
||||
preferences: 'Preferences',
|
||||
},
|
||||
personalHint: 'Your name as it appears on official EMA documents.',
|
||||
languageTitle: 'Language',
|
||||
languageHint: 'Choose the language used across the admin panel.',
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
subtitle: 'Select how the admin panel looks on your device.',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
system: 'System',
|
||||
},
|
||||
twoStep: {
|
||||
title: 'Two-step verification',
|
||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Email notifications',
|
||||
desc: 'Receive updates about your account activity by email.',
|
||||
},
|
||||
strength: {
|
||||
label: 'Password strength',
|
||||
weak: 'Weak',
|
||||
fair: 'Fair',
|
||||
good: 'Good',
|
||||
strong: 'Strong',
|
||||
},
|
||||
profileUpdated: 'Profile updated successfully',
|
||||
passwordChanged: 'Password changed successfully',
|
||||
updateFailed: 'Could not update profile. Please try again.',
|
||||
passwordFailed:
|
||||
'Could not change password. Check your current password and try again.',
|
||||
fields: {
|
||||
fullNameEn: 'Full name (English)',
|
||||
fullNameAm: 'Full name (Amharic)',
|
||||
username: 'Username',
|
||||
usernameHint: 'Username cannot be changed',
|
||||
organization: 'Organization',
|
||||
email: 'Email address',
|
||||
phone: 'Phone number',
|
||||
address: 'Address',
|
||||
language: 'Preferred language',
|
||||
currentPassword: 'Current password',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm new password',
|
||||
},
|
||||
validation: {
|
||||
nameRequired: 'Name is required',
|
||||
emailInvalid: 'Enter a valid email',
|
||||
usernameRequired: 'Username is required',
|
||||
phoneRequired: 'Phone number is required',
|
||||
passwordMin: 'Password must be at least 8 characters',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createBrowserRouter,
|
||||
RouterProvider,
|
||||
Navigate,
|
||||
Outlet,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
LoginPage,
|
||||
@@ -14,6 +13,7 @@ import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -33,8 +33,8 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
],
|
||||
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user