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,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);
}

View 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();
}
/** 04 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>
);
}

View File

@@ -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: 'የይለፍ ቃላት አይዛመዱም',
},
},
};

View File

@@ -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;

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>
);
}

View File

@@ -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 /> },
],
},
],
},

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

@@ -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>

View File

@@ -1,3 +1,9 @@
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';
export * from './lib/layout/PageHeader';

View File

@@ -0,0 +1,271 @@
import {
Anchor,
Burger,
Group,
Indicator,
Menu,
UnstyledButton,
rem,
} from '@mantine/core';
import {
IconBell,
IconChevronRight,
IconLogout,
IconUserCircle,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from './LanguageSwitcher';
import { ColorSchemeToggle } from './ColorSchemeToggle';
import { BrandAvatar } from './BrandAvatar';
export interface Breadcrumb {
label: string;
path: string;
}
interface AppHeaderProps {
onToggleNav: () => void;
onToggleSidebar: () => void;
navOpened: boolean;
breadcrumbs: Breadcrumb[];
onNavigate: (path: string) => void;
onLogout: () => void;
userName?: string;
userInitials?: string;
supportedLanguages: readonly string[];
}
export function AppHeader({
onToggleNav,
onToggleSidebar,
navOpened,
breadcrumbs,
onNavigate,
onLogout,
userName = 'User',
userInitials = '?',
supportedLanguages,
}: AppHeaderProps) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return (
<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 ? onToggleNav : onToggleSidebar}
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 */}
<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',
}}
>
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.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={() => onNavigate(crumb.path)}
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 supportedLanguages={supportedLanguages} />
<ColorSchemeToggle />
{/* Notification bell */}
<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 initials={userInitials} 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),
}}
>
{userName}
</Menu.Label>
<Menu.Item
leftSection={<IconUserCircle size={16} />}
onClick={() => onNavigate('/profile')}
style={{ borderRadius: rem(8) }}
>
Profile
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconLogout size={16} />}
onClick={onLogout}
style={{ borderRadius: rem(8) }}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,196 @@
import {
AppShell,
NavLink,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
rem,
} from '@mantine/core';
import {
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BrandMark } from '@ema-platform/auth';
export interface NavItem {
label: string;
icon: Icon;
to?: string;
soon?: boolean;
}
interface AppSidebarProps {
navItems: NavItem[];
collapsed: boolean;
activePath: string;
onToggleCollapse: () => void;
onNavigate: (item: NavItem) => void;
brandName: string;
brandSubtitle: string;
}
export function AppSidebar({
navItems,
collapsed,
activePath,
onToggleCollapse,
onNavigate,
brandName,
brandSubtitle,
}: AppSidebarProps) {
const { t } = useTranslation();
const activeNavItem = (item: NavItem) =>
!!item.to &&
(activePath === item.to || activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(12),
height: 74,
padding: collapsed ? '0 12px' : '0 20px',
borderBottom: '1px solid var(--mantine-color-gray-2)',
justifyContent: collapsed ? 'center' : 'flex-start',
flexShrink: 0,
}}
>
<BrandMark size={32} />
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
fw={800}
size="xs"
style={{
textTransform: 'uppercase',
letterSpacing: '0.08em',
lineHeight: 1.2,
color: 'var(--mantine-color-primary-7)',
}}
>
{brandName}
</Text>
<Text
size="xs"
c="dimmed"
style={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: rem(10),
lineHeight: 1.3,
}}
>
{brandSubtitle}
</Text>
</div>
)}
</div>
{/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={4}>
{navItems.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip key={item.label} label={item.label} position="right" withArrow>
<UnstyledButton
onClick={() => onNavigate(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>
);
}
return (
<NavLink
key={item.label}
active={active}
label={item.label}
leftSection={<ItemIcon size={19} stroke={1.6} />}
onClick={() => onNavigate(item)}
variant="light"
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
/>
);
})}
</Stack>
</AppShell.Section>
{/* Collapse toggle */}
<div
style={{
padding: rem(8),
borderTop: '1px solid var(--mantine-color-gray-2)',
flexShrink: 0,
}}
>
<Tooltip
label={collapsed ? t('nav.expandSidebar', 'Expand sidebar') : t('nav.collapseSidebar', 'Collapse sidebar')}
position="right"
withArrow
disabled={!collapsed}
>
<UnstyledButton
onClick={onToggleCollapse}
visibleFrom="sm"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? '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)';
}}
>
{collapsed ? (
<IconChevronRight size={18} stroke={1.6} />
) : (
<>
<IconChevronLeft size={18} stroke={1.6} />
<span>{t('nav.collapseSidebar', 'Collapse')}</span>
</>
)}
</UnstyledButton>
</Tooltip>
</div>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { Box, useMantineTheme } from '@mantine/core';
export function BrandAvatar({
initials = 'AB',
initials = '?',
size = 38,
}: {
initials?: string;

View File

@@ -1,18 +1,18 @@
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 {
supportedLanguages: readonly string[];
/** `icon` renders a compact globe button; `button` shows the language label. */
variant?: 'icon' | 'button';
}
export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: LanguageSwitcherProps) {
const { t, i18n } = useTranslation();
const current = i18n.language as AppLanguage;
const current = i18n.language;
const change = (lng: AppLanguage) => {
const change = (lng: string) => {
if (lng !== current) i18n.changeLanguage(lng);
};
@@ -65,7 +65,7 @@ export function LanguageSwitcher({ variant = 'icon' }: LanguageSwitcherProps) {
style={{ borderRadius: rem(12), padding: rem(6) }}
>
<Menu.Label>{t('language.label')}</Menu.Label>
{SUPPORTED_LANGUAGES.map((lng) => (
{supportedLanguages.map((lng) => (
<Menu.Item
key={lng}
onClick={() => change(lng)}

1140
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"scripts": {
"backoffice": "npm run build:user-management && nx serve @ema-platform/backoffice",
"portal": "nx serve @ema-platform/portal",
"dev:all": "nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
"dev:all": "npm run build:user-management && nx run-many -t serve -p @ema-platform/portal @ema-platform/backoffice --parallel=2",
"build:backoffice": "nx build @ema-platform/backoffice",
"build:portal": "nx build @ema-platform/portal",
"lint": "nx run-many -t lint",
@@ -15,6 +15,8 @@
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
},
"dependencies": {
"@daypicker/ethiopic": "^10.0.1",
"@daypicker/react": "^10.0.1",
"@emotion/react": "^11.14.0",
"@hookform/resolvers": "^5.2.2",
"@mantine/core": "^8.3.17",