Merge branch 'feature/pencil-design' of github.com:Tria-plc/emaui into feature/pencil-design

This commit is contained in:
Mengisteab
2026-06-16 08:34:34 +00:00
9 changed files with 303 additions and 80 deletions

View File

@@ -1,4 +1,5 @@
import { useState, type ElementType, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
Paper,
Text,
@@ -64,21 +65,25 @@ const ROLES = [
{ name: 'Guest', value: 10, color: '#93c5fd' },
];
const KPIS = [
{ label: 'Total Users', value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true },
{ label: 'Active Users', value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true },
{ label: 'New This Month', value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true },
{ label: 'Pending Approvals', value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false },
];
function useKpis(t: (key: string) => string) {
return [
{ label: t('dashboard.kpis.totalUsers'), value: '9,431', icon: IconUsers, color: 'blue', trend: '+12.5%', up: true },
{ label: t('dashboard.kpis.activeUsers'), value: '7,218', icon: IconUserCheck, color: 'indigo', trend: '+8.2%', up: true },
{ label: t('dashboard.kpis.newThisMonth'), value: '642', icon: IconUserPlus, color: 'cyan', trend: '+23.1%', up: true },
{ label: t('dashboard.kpis.pendingApprovals'), value: '37', icon: IconClockHour4, color: 'orange', trend: '-4.0%', up: false },
];
}
const QUICK_LINKS = [
{ label: 'Add User', desc: 'Create a new account', icon: IconUserPlus, color: 'blue' },
{ label: 'Roles & Permissions', desc: 'Manage access levels', icon: IconShieldLock, color: 'indigo' },
{ label: 'Invite Members', desc: 'Send email invites', icon: IconMail, color: 'cyan' },
{ label: 'Import Users', desc: 'Bulk CSV import', icon: IconUpload, color: 'violet' },
{ label: 'Export Data', desc: 'Download reports', icon: IconDownload, color: 'blue' },
{ label: 'Audit Log', desc: 'Review activity', icon: IconClipboardList, color: 'grape' },
];
function useQuickLinks(t: (key: string) => string) {
return [
{ label: t('dashboard.quickLinks.addUser'), desc: t('dashboard.quickLinks.addUserDesc'), icon: IconUserPlus, color: 'blue' },
{ label: t('dashboard.quickLinks.rolesAndPermissions'), desc: t('dashboard.quickLinks.rolesAndPermissionsDesc'), icon: IconShieldLock, color: 'indigo' },
{ label: t('dashboard.quickLinks.inviteMembers'), desc: t('dashboard.quickLinks.inviteMembersDesc'), icon: IconMail, color: 'cyan' },
{ label: t('dashboard.quickLinks.importUsers'), desc: t('dashboard.quickLinks.importUsersDesc'), icon: IconUpload, color: 'violet' },
{ label: t('dashboard.quickLinks.exportData'), desc: t('dashboard.quickLinks.exportDataDesc'), icon: IconDownload, color: 'blue' },
{ label: t('dashboard.quickLinks.auditLog'), desc: t('dashboard.quickLinks.auditLogDesc'), icon: IconClipboardList, color: 'grape' },
];
}
type UserStatus = 'Active' | 'Pending' | 'Invited';
const STATUS_COLOR: Record<UserStatus, string> = {
@@ -214,16 +219,19 @@ function QuickLinkTile({
/* ------------------------------------------------------------------ */
export function DashboardPage() {
const { t } = useTranslation();
const [range, setRange] = useState('week');
const kpis = useKpis(t);
const quickLinks = useQuickLinks(t);
return (
<Stack gap="xl">
{/* header */}
<Group justify="space-between" align="flex-end" wrap="wrap">
<Stack gap={4}>
<Title order={2}>Overview</Title>
<Title order={2}>{t('dashboard.title')}</Title>
<Text c="dimmed" size="sm">
Welcome back here&apos;s what&apos;s happening across your platform.
{t('dashboard.subtitle')}
</Text>
</Stack>
<Group gap="sm">
@@ -232,20 +240,20 @@ export function DashboardPage() {
onChange={setRange}
radius="md"
data={[
{ label: 'Today', value: 'today' },
{ label: 'This Week', value: 'week' },
{ label: 'This Month', value: 'month' },
{ label: t('dashboard.timeRange.today'), value: 'today' },
{ label: t('dashboard.timeRange.thisWeek'), value: 'week' },
{ label: t('dashboard.timeRange.thisMonth'), value: 'month' },
]}
/>
<Button leftSection={<IconDownload size={16} />} radius="md">
Export
{t('common.export')}
</Button>
</Group>
</Group>
{/* KPIs */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{KPIS.map((k) => (
{kpis.map((k) => (
<StatCard key={k.label} {...k} />
))}
</SimpleGrid>
@@ -256,15 +264,15 @@ export function DashboardPage() {
<SectionCard>
<Group justify="space-between" align="flex-start" mb="lg">
<CardHeading
title="User Registrations"
subtitle="New sign-ups over the last 8 months"
title={t('dashboard.charts.userRegistrations')}
subtitle={t('dashboard.charts.registrationsSubtitle')}
/>
<Stack gap={0} align="flex-end">
<Text fw={700} c="teal">
+18.2%
</Text>
<Text size="xs" c="dimmed">
vs previous period
{t('dashboard.charts.vsPreviousPeriod')}
</Text>
</Stack>
</Group>
@@ -309,7 +317,7 @@ export function DashboardPage() {
<Grid.Col span={{ base: 12, lg: 4 }}>
<SectionCard>
<CardHeading title="Users by Role" subtitle="Distribution across access levels" />
<CardHeading title={t('dashboard.charts.usersByRole')} subtitle={t('dashboard.charts.roleDistribution')} />
<Group mt="lg" justify="center" wrap="nowrap" gap="lg">
<Box pos="relative" w={168} h={168} style={{ flexShrink: 0 }}>
<ResponsiveContainer width="100%" height="100%">
@@ -348,7 +356,7 @@ export function DashboardPage() {
9,431
</Text>
<Text size="xs" c="dimmed">
Total users
{t('dashboard.charts.totalUsers')}
</Text>
</Stack>
</Box>
@@ -376,9 +384,9 @@ export function DashboardPage() {
<Grid gutter="lg" align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<SectionCard>
<CardHeading title="Quick Links" subtitle="Common administrative shortcuts" />
<CardHeading title={t('dashboard.quickLinks.title')} subtitle={t('dashboard.quickLinks.subtitle')} />
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="md" mt="lg">
{QUICK_LINKS.map((q) => (
{quickLinks.map((q) => (
<QuickLinkTile key={q.label} {...q} />
))}
</SimpleGrid>
@@ -389,11 +397,11 @@ export function DashboardPage() {
<SectionCard>
<Group justify="space-between" mb="md">
<Text fw={700} fz="lg">
Recent Users
{t('dashboard.recentUsers.title')}
</Text>
<Anchor size="sm" fw={600}>
<Group gap={4} wrap="nowrap">
View all
{t('common.viewAll')}
<IconArrowRight size={14} />
</Group>
</Anchor>

View File

@@ -0,0 +1,47 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { en } from './locales/en';
import { am } from './locales/am';
export const SUPPORTED_LANGUAGES = ['en', 'am'] as const;
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const STORAGE_KEY = 'ema-backoffice-lang';
function getInitialLanguage(): AppLanguage {
const stored =
typeof localStorage !== 'undefined'
? (localStorage.getItem(STORAGE_KEY) as AppLanguage | null)
: null;
if (stored && SUPPORTED_LANGUAGES.includes(stored)) return stored;
return 'en';
}
export const i18n = i18next.createInstance();
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
am: { translation: am },
},
lng: getInitialLanguage(),
fallbackLng: 'en',
supportedLngs: [...SUPPORTED_LANGUAGES],
interpolation: { escapeValue: false },
returnNull: false,
});
i18n.on('languageChanged', (lng) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, lng);
}
if (typeof document !== 'undefined') {
document.documentElement.lang = lng;
}
});
if (typeof document !== 'undefined') {
document.documentElement.lang = i18n.language;
}
export default i18n;

View File

@@ -0,0 +1,82 @@
import type { Translations } from './en';
export const am: Translations = {
app: {
name: 'ኢማ አስተዳደር',
shortName: 'ኢማ',
authority: 'የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን',
tagline: 'የቁጥጥር ማዕከል',
},
language: {
label: 'ቋንቋ',
en: 'English',
am: 'አማርኛ',
},
nav: {
menu: 'ምናሌ',
dashboard: 'ዳሽቦርድ',
userManagement: 'የተጠቃሚ አስተዳደር',
},
common: {
logout: 'ውጣ',
profile: 'መገለጫ',
settings: 'ቅንብሮች',
export: 'ላክ',
viewAll: 'ሁሉንም ይመልከቱ',
home: 'መነሻ',
notifications: 'ማሳወቂያዎች',
administrator: 'አስተዳዳሪ',
},
breadcrumbs: {
dashboard: 'ዳሽቦርድ',
um: 'የተጠቃሚ አስተዳደር',
home: 'መነሻ',
},
dashboard: {
title: 'አጠቃላይ እይታ',
subtitle: 'እንኳን ደህና መጡ — በመድረክዎ ላይ ያለው እንቅስቃሴ አጠቃላይ እይታ።',
kpis: {
totalUsers: 'ጠቅላላ ተጠቃሚዎች',
activeUsers: 'ንቁ ተጠቃሚዎች',
newThisMonth: 'በዚህ ወር አዲስ',
pendingApprovals: 'በመጠባበቅ ላይ ያሉ',
},
charts: {
userRegistrations: 'የተጠቃሚ ምዝገባ',
registrationsSubtitle: 'ባለፉት 8 ወራት አዲስ ምዝገባ',
vsPreviousPeriod: 'ካለፈው ጊዜ ጋር ሲነጻጸር',
usersByRole: 'ተጠቃሚዎች በሚና',
roleDistribution: 'በተደራሽነት ደረጃ ስርጭት',
totalUsers: 'ጠቅላላ ተጠቃሚዎች',
},
quickLinks: {
title: 'ፈጣን አገናኞች',
subtitle: 'የተለመዱ የአስተዳደር አቋራጮች',
addUser: 'ተጠቃሚ ያክሉ',
addUserDesc: 'አዲስ መለያ ይፍጠሩ',
rolesAndPermissions: 'ሚናዎች እና ፈቃዶች',
rolesAndPermissionsDesc: 'የተደራሽነት ደረጃዎችን ያስተዳድሩ',
inviteMembers: 'አባላትን ይጋብዙ',
inviteMembersDesc: 'በኢሜይል ግብዣ ይላኩ',
importUsers: 'ተጠቃሚዎችን ያስመጡ',
importUsersDesc: 'የCSV ጅምላ ማስመጣት',
exportData: 'ውሂብ ያላኩ',
exportDataDesc: 'ሪፖርቶችን ያውርዱ',
auditLog: 'የኦዲት መዝገብ',
auditLogDesc: 'እንቅስቃሴን ይገምግሙ',
},
recentUsers: {
title: 'የቅርብ ጊዜ ተጠቃሚዎች',
},
timeRange: {
today: 'ዛሬ',
thisWeek: 'በዚህ ሳምንት',
thisMonth: 'በዚህ ወር',
},
},
};

View File

@@ -0,0 +1,82 @@
export const en = {
app: {
name: 'EMA Admin',
shortName: 'EMA',
authority: 'Ethiopian Maritime Authority',
tagline: 'Control Center',
},
language: {
label: 'Language',
en: 'English',
am: 'አማርኛ',
},
nav: {
menu: 'MENU',
dashboard: 'Dashboard',
userManagement: 'User Management',
},
common: {
logout: 'Log out',
profile: 'Profile',
settings: 'Settings',
export: 'Export',
viewAll: 'View all',
home: 'Home',
notifications: 'Notifications',
administrator: 'Administrator',
},
breadcrumbs: {
dashboard: 'Dashboard',
um: 'User Management',
home: 'Home',
},
dashboard: {
title: 'Overview',
subtitle: 'Welcome back — here\u2019s what\u2019s happening across your platform.',
kpis: {
totalUsers: 'Total Users',
activeUsers: 'Active Users',
newThisMonth: 'New This Month',
pendingApprovals: 'Pending Approvals',
},
charts: {
userRegistrations: 'User Registrations',
registrationsSubtitle: 'New sign-ups over the last 8 months',
vsPreviousPeriod: 'vs previous period',
usersByRole: 'Users by Role',
roleDistribution: 'Distribution across access levels',
totalUsers: 'Total users',
},
quickLinks: {
title: 'Quick Links',
subtitle: 'Common administrative shortcuts',
addUser: 'Add User',
addUserDesc: 'Create a new account',
rolesAndPermissions: 'Roles & Permissions',
rolesAndPermissionsDesc: 'Manage access levels',
inviteMembers: 'Invite Members',
inviteMembersDesc: 'Send email invites',
importUsers: 'Import Users',
importUsersDesc: 'Bulk CSV import',
exportData: 'Export Data',
exportDataDesc: 'Download reports',
auditLog: 'Audit Log',
auditLogDesc: 'Review activity',
},
recentUsers: {
title: 'Recent Users',
},
timeRange: {
today: 'Today',
thisWeek: 'This Week',
thisMonth: 'This Month',
},
},
};
export type Translations = typeof en;

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import {
Group,
Text,
@@ -29,23 +30,19 @@ interface AppHeaderProps {
onToggle: () => void;
}
const ROUTE_LABELS: Record<string, string> = {
dashboard: 'Dashboard',
um: 'User Management',
};
function useBreadcrumbs() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const segments = location.pathname.split('/').filter(Boolean);
const crumbs = [
{ label: 'Home', path: '/dashboard', icon: <IconHome size={14} /> },
{ label: t('breadcrumbs.home'), path: '/dashboard', icon: <IconHome size={14} /> },
...segments
.filter((seg) => ROUTE_LABELS[seg])
.filter((seg) => ['dashboard', 'um'].includes(seg))
.map((seg, i) => ({
label: ROUTE_LABELS[seg],
label: t(`breadcrumbs.${seg}`),
path: '/' + segments.slice(0, i + 1).join('/'),
icon: null,
})),
@@ -55,6 +52,7 @@ function useBreadcrumbs() {
}
export function AppHeader({ onToggle }: AppHeaderProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const { crumbs } = useBreadcrumbs();
@@ -106,7 +104,7 @@ export function AppHeader({ onToggle }: AppHeaderProps) {
<ColorSchemeToggle />
<Indicator color="red" size={8} offset={6} withBorder>
<ActionIcon variant="default" size={38} radius="md" aria-label="Notifications">
<ActionIcon variant="default" size={38} radius="md" aria-label={t('common.notifications')}>
<IconBell size={19} />
</ActionIcon>
</Indicator>
@@ -123,7 +121,7 @@ export function AppHeader({ onToggle }: AppHeaderProps) {
Amanuel D.
</Text>
<Text size="xs" c="dimmed" visibleFrom="sm">
Administrator
{t('common.administrator')}
</Text>
</div>
<IconChevronDown size={14} />
@@ -131,11 +129,11 @@ export function AppHeader({ onToggle }: AppHeaderProps) {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconUserCircle size={16} />}>Profile</Menu.Item>
<Menu.Item leftSection={<IconSettings size={16} />}>Settings</Menu.Item>
<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}>
Logout
{t('common.logout')}
</Menu.Item>
</Menu.Dropdown>
</Menu>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import {
AppShell,
Box,
@@ -20,12 +21,8 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { logout } from '@ema-platform/auth';
const NAV_ITEMS = [
{ label: 'Dashboard', icon: IconLayoutDashboard, path: '/dashboard' },
{ label: 'User Management', icon: IconUsers, path: '/um/user-management/dashboard' },
];
export function AppSidebar() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { pathname } = useLocation();
@@ -44,10 +41,10 @@ export function AppSidebar() {
</ThemeIcon>
<div>
<Text fw={700} lh={1.1}>
EMA Admin
{t('app.name')}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
Control Center
{t('app.tagline')}
</Text>
</div>
</Group>
@@ -56,20 +53,28 @@ export function AppSidebar() {
<AppShell.Section grow component={ScrollArea}>
<Stack gap={4}>
<Text size="xs" fw={700} c="dimmed" px="xs" mb={4} style={{ letterSpacing: 1 }}>
MENU
{t('nav.menu')}
</Text>
{NAV_ITEMS.map(({ label, icon: Icon, path }) => (
<NavLink
key={path}
label={label}
leftSection={<Icon size={19} stroke={1.6} />}
active={pathname.startsWith(path)}
onClick={() => navigate(path)}
variant="light"
color="blue"
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
/>
))}
<NavLink
key="dashboard"
label={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 } }}
/>
<NavLink
key="um"
label={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 } }}
/>
</Stack>
</AppShell.Section>
@@ -91,13 +96,13 @@ export function AppSidebar() {
Amanuel D.
</Text>
<Text size="xs" c="dimmed">
Administrator
{t('common.administrator')}
</Text>
</Box>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Log out"
aria-label={t('common.logout')}
onClick={handleLogout}
>
<IconLogout size={18} />

View File

@@ -1,31 +1,27 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, ActionIcon } from '@mantine/core';
import { IconWorld, IconCheck } from '@tabler/icons-react';
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'am', label: 'አማርኛ' },
];
import { i18n, SUPPORTED_LANGUAGES } from '../../i18n/config';
export function LanguageSwitcher() {
const [current, setCurrent] = useState('en');
const { t } = useTranslation();
return (
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="default" size={38} radius="md" aria-label="Language">
<ActionIcon variant="default" size={38} radius="md" aria-label={t('language.label')}>
<IconWorld size={19} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Language</Menu.Label>
{LANGUAGES.map((lng) => (
<Menu.Label>{t('language.label')}</Menu.Label>
{SUPPORTED_LANGUAGES.map((code) => (
<Menu.Item
key={lng.code}
onClick={() => setCurrent(lng.code)}
rightSection={current === lng.code ? <IconCheck size={16} /> : undefined}
key={code}
onClick={() => i18n.changeLanguage(code)}
rightSection={i18n.language === code ? <IconCheck size={16} /> : undefined}
>
{lng.label}
{t(`language.${code}`)}
</Menu.Item>
))}
</Menu.Dropdown>

View File

@@ -1,8 +1,10 @@
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
import { i18n } from '../i18n/config';
import { MantineThemeProvider } from './MantineThemeProvider';
const queryClient = new QueryClient({
@@ -24,7 +26,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
<I18nextProvider i18n={i18n}>
<MantineThemeProvider>{children}</MantineThemeProvider>
</I18nextProvider>
</AuthConfigProvider>
</QueryClientProvider>
</Provider>

View File

@@ -4,6 +4,7 @@ import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import './styles.css';
import './app/i18n/config';
import { App } from './app/app';
window.__USER_MANAGEMENT_BRANDING__ = {