From 85e070db33ffee268b366a962ec821d2359125a4 Mon Sep 17 00:00:00 2001 From: mengstabketemaw Date: Wed, 17 Jun 2026 13:50:20 +0300 Subject: [PATCH] merge design protal and backoffice --- .../profile/pages/ProfilePage.module.css | 56 + .../features/profile/pages/ProfilePage.tsx | 605 +++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 77 ++ apps/backoffice/src/app/i18n/locales/en.ts | 78 ++ .../src/app/layouts/BackofficeLayout.tsx | 109 +- .../src/app/layouts/components/AppHeader.tsx | 143 --- .../src/app/layouts/components/AppSidebar.tsx | 116 -- .../layouts/components/ColorSchemeToggle.tsx | 20 - .../layouts/components/LanguageSwitcher.tsx | 30 - apps/backoffice/src/app/router/index.tsx | 4 +- .../src/app/components/AmharicDatePicker.tsx | 124 ++ .../features/profile/pages/ProfilePage.tsx | 5 +- .../pages/SeafarerRegistrationPage.tsx | 9 +- .../features/support/pages/SupportPage.tsx | 2 +- apps/portal/src/app/layouts/PortalLayout.tsx | 456 +------ libs/ui/src/index.ts | 6 + libs/ui/src/lib/layout/AppHeader.tsx | 271 ++++ libs/ui/src/lib/layout/AppSidebar.tsx | 196 +++ .../ui/src/lib/layout/BrandAvatar.tsx | 2 +- .../ui/src/lib/layout}/ColorSchemeToggle.tsx | 0 .../ui/src/lib/layout}/LanguageSwitcher.tsx | 10 +- .../ui/src/lib/layout}/PageHeader.tsx | 0 package-lock.json | 1140 +++++++++++++++-- package.json | 4 +- user-management | 2 +- 25 files changed, 2618 insertions(+), 847 deletions(-) create mode 100644 apps/backoffice/src/app/features/profile/pages/ProfilePage.module.css create mode 100644 apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx delete mode 100644 apps/backoffice/src/app/layouts/components/AppHeader.tsx delete mode 100644 apps/backoffice/src/app/layouts/components/AppSidebar.tsx delete mode 100644 apps/backoffice/src/app/layouts/components/ColorSchemeToggle.tsx delete mode 100644 apps/backoffice/src/app/layouts/components/LanguageSwitcher.tsx create mode 100644 apps/portal/src/app/components/AmharicDatePicker.tsx create mode 100644 libs/ui/src/lib/layout/AppHeader.tsx create mode 100644 libs/ui/src/lib/layout/AppSidebar.tsx rename apps/portal/src/app/components/ui.tsx => libs/ui/src/lib/layout/BrandAvatar.tsx (96%) rename {apps/portal/src/app/components => libs/ui/src/lib/layout}/ColorSchemeToggle.tsx (100%) rename {apps/portal/src/app/components => libs/ui/src/lib/layout}/LanguageSwitcher.tsx (90%) rename {apps/portal/src/app/components => libs/ui/src/lib/layout}/PageHeader.tsx (100%) diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.module.css b/apps/backoffice/src/app/features/profile/pages/ProfilePage.module.css new file mode 100644 index 000000000..3c37f92a1 --- /dev/null +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.module.css @@ -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); +} diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx new file mode 100644 index 000000000..3ee3aae2e --- /dev/null +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx @@ -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(); + const [meTrigger] = useApiMutation(); + const [passwordTrigger] = useApiMutation(); + + 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; + + const { + register: registerProfile, + handleSubmit: handleProfileSubmit, + reset: resetProfile, + formState: { errors: profileErrors }, + } = useForm({ + 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; + + const { + register: registerPassword, + handleSubmit: handlePasswordSubmit, + reset: resetPassword, + watch: watchPassword, + formState: { errors: passwordErrors }, + } = useForm({ + 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 = { 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 ( + + + + {/* Profile summary */} + + + + + {getInitials(displayName, user?.email ?? '')} + + + +
+ + {displayName || '\u2014'} + + {user?.isPhoneNumberVerified + ? t('profile.verified') + : t('profile.unverified')} + + + + + + {user?.email} + + +
+ + + + {user?.username && ( + } + > + {user.username} + + )} +
+
+ + {/* Tabs */} + + + }> + {t('profile.tabs.profile')} + + }> + {t('profile.tabs.security')} + + }> + {t('profile.tabs.preferences')} + + + + {/* ---- Profile ---- */} + + +
+ +
+ {t('profile.personal')} + + {t('profile.personalHint')} + + + } + error={profileErrors.nameEn?.message} + {...registerProfile('nameEn')} + /> + } + error={profileErrors.nameAm?.message} + {...registerProfile('nameAm')} + /> + } + error={profileErrors.username?.message} + {...registerProfile('username')} + /> + +
+ + + +
+ + {t('profile.contact')} + + + } + error={profileErrors.email?.message} + {...registerProfile('email')} + /> + } + error={profileErrors.phoneNumber?.message} + {...registerProfile('phoneNumber')} + /> + +
+ + + + + +
+
+
+
+ + {/* ---- Security ---- */} + + +
+ +
+ {t('profile.security')} + + {t('profile.securityHint')} + + + } + error={passwordErrors.oldPassword?.message} + {...registerPassword('oldPassword')} + /> + + } + error={passwordErrors.newPassword?.message} + {...registerPassword('newPassword')} + /> + } + error={passwordErrors.confirmPassword?.message} + {...registerPassword('confirmPassword')} + /> + + + {score > 0 && ( + + + + {t('profile.strength.label')} + + + {strengthLabels[score]} + + + + {[1, 2, 3, 4].map((i) => ( + + ))} + + + )} + +
+ + + + +
+ {t('profile.twoStep.title')} + + {t('profile.twoStep.desc')} + +
+ setTwoStepEnabled(e.currentTarget.checked)} + /> +
+ + + + +
+
+
+
+ + {/* ---- Preferences ---- */} + + + +
+ {t('profile.languageTitle')} + + {t('profile.languageHint')} + + + {SUPPORTED_LANGUAGES.map((lng) => { + const active = i18n.language === lng; + return ( + i18n.changeLanguage(lng)} + className={`${classes.choice} ${active ? classes.choiceActive : ''}`} + p="md" + > + + {flags[lng as AppLanguage]} +
+ + {t(`language.${lng}`)} + + + {lng === 'en' ? 'English (United States)' : 'Amharic'} + +
+ {active ? ( + + ) : ( + + )} +
+
+ ); + })} +
+
+ + + +
+ {t('profile.appearance.title')} + + {t('profile.appearance.subtitle')} + + + {appearanceOptions.map(({ value, label, icon: Icon }) => { + const active = colorScheme === value; + return ( + setColorScheme(value)} + className={`${classes.choice} ${active ? classes.choiceActive : ''}`} + p="md" + > + + + + {label} + + {active && ( + + )} + + + ); + })} + +
+ + + + + + +
+ {t('profile.notifications.title')} + + {t('profile.notifications.desc')} + +
+
+ setEmailNotifications(e.currentTarget.checked)} + /> +
+ + + + +
+
+
+
+
+ ); +} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 2ee6430c4..3b3b26867 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: 'የይለፍ ቃላት አይዛመዱም', + }, + }, }; diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index b0d4106a7..8979d7a1a 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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; diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index 3c9592a36..d8bf881e8 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -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 ( - - + + - - + + - +
+ +
); diff --git a/apps/backoffice/src/app/layouts/components/AppHeader.tsx b/apps/backoffice/src/app/layouts/components/AppHeader.tsx deleted file mode 100644 index 85d2216a0..000000000 --- a/apps/backoffice/src/app/layouts/components/AppHeader.tsx +++ /dev/null @@ -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: }, - ...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 ( - - - - - {crumbs.map((crumb, i) => - i < crumbs.length - 1 ? ( - navigate(crumb.path)} - style={{ display: 'flex', alignItems: 'center', gap: 4 }} - > - {crumb.icon} - {crumb.label} - - ) : ( - - {crumb.icon} - {crumb.label} - - ), - )} - - - - - - - - - - - - - - - - - - - AD - -
- - Amanuel D. - - - {t('common.administrator')} - -
- -
-
-
- - }>{t('common.profile')} - }>{t('common.settings')} - - } onClick={handleLogout}> - {t('common.logout')} - - -
-
-
- ); -} diff --git a/apps/backoffice/src/app/layouts/components/AppSidebar.tsx b/apps/backoffice/src/app/layouts/components/AppSidebar.tsx deleted file mode 100644 index a27dc86fe..000000000 --- a/apps/backoffice/src/app/layouts/components/AppSidebar.tsx +++ /dev/null @@ -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 ( - <> - - - - - - {!collapsed && ( -
- - {t('app.name')} - - - {t('app.tagline')} - -
- )} -
-
- - - - {!collapsed && ( - - {t('nav.menu')} - - )} - } - active={pathname.startsWith('/dashboard')} - onClick={() => navigate('/dashboard')} - variant="light" - color="blue" - styles={{ - root: { borderRadius: rem(10) }, - label: { fontWeight: 500, display: collapsed ? 'none' : 'block' }, - }} - /> - } - 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' }, - }} - /> - - - - - - {!collapsed && ( - - {t('common.collapse')} - - )} - - {collapsed ? : } - - - - - ); -} diff --git a/apps/backoffice/src/app/layouts/components/ColorSchemeToggle.tsx b/apps/backoffice/src/app/layouts/components/ColorSchemeToggle.tsx deleted file mode 100644 index 1f59f111b..000000000 --- a/apps/backoffice/src/app/layouts/components/ColorSchemeToggle.tsx +++ /dev/null @@ -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 ( - setColorScheme(isDark ? 'light' : 'dark')} - > - {isDark ? : } - - ); -} diff --git a/apps/backoffice/src/app/layouts/components/LanguageSwitcher.tsx b/apps/backoffice/src/app/layouts/components/LanguageSwitcher.tsx deleted file mode 100644 index 924689d9c..000000000 --- a/apps/backoffice/src/app/layouts/components/LanguageSwitcher.tsx +++ /dev/null @@ -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 ( - - - - - - - - {t('language.label')} - {SUPPORTED_LANGUAGES.map((code) => ( - i18n.changeLanguage(code)} - rightSection={i18n.language === code ? : undefined} - > - {t(`language.${code}`)} - - ))} - - - ); -} diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index b145d02fd..91e344da6 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -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: }, { path: 'dashboard', element: }, + { path: 'profile', element: }, ], - }, ], }, diff --git a/apps/portal/src/app/components/AmharicDatePicker.tsx b/apps/portal/src/app/components/AmharicDatePicker.tsx new file mode 100644 index 000000000..3798fcddc --- /dev/null +++ b/apps/portal/src/app/components/AmharicDatePicker.tsx @@ -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 ( + + + { + e.stopPropagation(); + setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN')); + }} + aria-label="Switch calendar type" + > + {calendarType} + + } + leftSectionWidth="calc(4.375rem * var(--mantine-scale))" + rightSection={ + + + + } + /> + + + + {calendarType === 'AMH' ? ( + { + onChange?.(date ?? null); + close(); + }} + /> + ) : ( + { + onChange?.(date ?? null); + close(); + }} + /> + )} + + + ); +} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 48de3620d..2c4493fa9 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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) { diff --git a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx index b1ac7eb85..5b94cfd52 100644 --- a/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/SeafarerRegistrationPage.tsx @@ -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({ en: '', am: '' }); const [lastName, setLastName] = useState({ en: '', am: '' }); const [gender, setGender] = useState(null); - const [dob, setDob] = useState(''); + const [dob, setDob] = useState(null); const [placeOfBirth, setPlaceOfBirth] = useState(''); const [nationality, setNationality] = useState('Ethiopian'); const [maritalStatus, setMaritalStatus] = useState(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() {