From 8389b2735d6f1eaf8ca6687adb8f9f644f53934b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 11:54:54 +0000 Subject: [PATCH] fix: sidebar in user management --- apps/edr-freight-web/backoffice/src/App.tsx | 16 +- .../src/layout/components/TenantConfig.ts | 8 +- .../components/common/Top.tsx | 94 ++--- .../src/user-management/AppMenuTabs.tsx | 379 +++++++----------- .../src/user-management/Applayout.tsx | 83 +++- 5 files changed, 271 insertions(+), 309 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f8e17fe76..70bcc4f9b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -626,7 +626,9 @@ const filterSidebarByPermission = ( const filterItems = (items: SidebarItem[]): SidebarItem[] => items .map((item) => - item.children ? { ...item, children: filterItems(item.children) } : item, + item.children + ? { ...item, children: filterItems(item.children) } + : item, ) .filter((item) => { if (etGl || djGl) { @@ -642,18 +644,6 @@ const filterSidebarByPermission = ( return true; }); - // Recursive: a group's own permission gates the whole subtree, leaves are - // checked individually, and a group with no surviving children disappears. - const filterItems = (items: SidebarItem[]): SidebarItem[] => - items.flatMap((item) => { - if (item.children?.length) { - if (item.permission && !permissionAllowed(item)) return []; - const children = filterItems(item.children); - return children.length ? [{ ...item, children }] : []; - } - return itemAllowed(item) ? [item] : []; - }); - return sections .map((section) => ({ ...section, diff --git a/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts b/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts index 758e2a75a..f8fd1f47d 100644 --- a/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts +++ b/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts @@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({ }); const defaultConfig: TenantConfig = { - appName: "Smart Office", - organizationName: "Smart Office", + appName: "EDR Freight", + organizationName: "Ethio-Djibouti Railways", canUseAttachmentFromDMS: false, logo: "/assets/TriaTradinglogo.png", primaryColor: "#1b354d", @@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = { const tenantConfigs: Record = { localhost: { - appName: "Smart Office", - organizationName: "Addis Ababa City Administration", + appName: "EDR Freight", + organizationName: "Ethio-Djibouti Railways", logo: "", primaryColor: "#0EA371", moduleConfig: { diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index 507e7da74..71afbee2d 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -104,9 +104,9 @@ const Top: React.FC = ({ const activePositionName = currentLanguage === "am" ? userDetails?.employee?.[0]?.positions?.[0]?.name?.am || - userDetails?.employee?.[0]?.positions?.[0]?.name?.en + userDetails?.employee?.[0]?.positions?.[0]?.name?.en : userDetails?.employee?.[0]?.positions?.[0]?.name?.en || - userDetails?.employee?.[0]?.positions?.[0]?.name?.am; + userDetails?.employee?.[0]?.positions?.[0]?.name?.am; const normalizedUserType = userDetails?.userType?.trim().toLowerCase() || ""; const userTypeLabel = @@ -114,8 +114,8 @@ const Top: React.FC = ({ ? t("header.user") : normalizedUserType ? normalizedUserType - .replace(/[_-]/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) : ""; const roleLabel = @@ -123,8 +123,8 @@ const Top: React.FC = ({ userTypeLabel || (normalizedUserType ? userDetails?.roles?.[0]?.key - ?.replace(/[:_]/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + ?.replace(/[:_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) : ""); const { permissionKeys } = useUserDetail(userDetails as MeDto); @@ -159,57 +159,57 @@ const Top: React.FC = ({ }, ...(moduleConfig.recordManagement ? [ - { - id: "recordManagement", - label: t("nav.Record Management", "Record Management"), - path: "/record-management/dashboard", - }, - ] + { + id: "recordManagement", + label: t("nav.Record Management", "Record Management"), + path: "/record-management/dashboard", + }, + ] : []), ...(moduleConfig.performance ? [ - { - id: "performanceManagement", - label: t("nav.PerformanceManagement", "Performance Management"), - path: "/performance-management/plan-years", - }, - ] + { + id: "performanceManagement", + label: t("nav.PerformanceManagement", "Performance Management"), + path: "/performance-management/plan-years", + }, + ] : []), ...(moduleConfig.objective ? [ - { - id: "objectiveManagement", - label: t("nav.objectiveManagement", "Objective Management"), - path: "/objective-management/plan-years", - }, - ] + { + id: "objectiveManagement", + label: t("nav.objectiveManagement", "Objective Management"), + path: "/objective-management/plan-years", + }, + ] : []), ...(moduleConfig.dms ? [ - { - id: "documentManagement", - label: t("nav.DocumentManagement", "Document Management"), - path: "/dms/dashboard", - }, - ] + { + id: "documentManagement", + label: t("nav.DocumentManagement", "Document Management"), + path: "/dms/dashboard", + }, + ] : []), ...(isOrgAdmin && moduleConfig.siteManagement ? [ - { - id: "orgAdmin", - label: t("nav.admin", "Admin"), - path: "/user-management/user_management-dashboard", - }, - ] + { + id: "orgAdmin", + label: t("nav.admin", "Admin"), + path: "/user-management/user_management-dashboard", + }, + ] : []), ...(isSuperAdmin ? [ - { - id: "superAdmin", - label: t("OrganizationAdmin", "Super Admin"), - path: "/user-management/dashboard", - }, - ] + { + id: "superAdmin", + label: t("OrganizationAdmin", "Super Admin"), + path: "/user-management/dashboard", + }, + ] : []), ]; @@ -256,8 +256,8 @@ const Top: React.FC = ({ }; return ( -
-
+
+
@@ -394,7 +394,7 @@ const Top: React.FC = ({ className={cn( "relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10", openNotifications && - "bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60", + "bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60", )} aria-label={t("header.notifications")} onClick={() => @@ -409,7 +409,7 @@ const Top: React.FC = ({ className={cn( "h-4 w-4 transition-colors", openNotifications && - "fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300", + "fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300", )} /> {unseenCount > 0 && ( @@ -479,7 +479,7 @@ const Top: React.FC = ({ className={cn( "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30", currentLanguage === lang.value && - "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", + "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", )} >
diff --git a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx index fb523fc06..127806334 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx @@ -1,4 +1,4 @@ -import { NavLink } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import { Archive, BarChart, @@ -8,28 +8,48 @@ import { FileText, Globe, Settings, - ShieldAlert, Users2, UsersRound, } from "lucide-react"; -import { useAuth } from "@/shared/context/AuthContext"; -import { usePermissions } from "@/shared/context/PermissionContext"; - import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/shared/common/ui/sidebar"; + export interface MenuItem { label: string; href: string; icon: React.ReactNode; roles?: string[]; - permissions?: string[]; - isPrimary?: boolean; - displayLabel?: string; - children?: MenuItem[]; + /** Sidebar section this item is bucketed under. */ + group: string; } +// Section render order; groups with no role-visible items are skipped. +const GROUP_ORDER = [ + "Overview", + "Organizations", + "Content", + "Records", + "Configuration", + "Archive", + "System", +]; + export const AppMenuTabs = () => { const { user } = useAuth(); + const { pathname } = useLocation(); + const { setOpenMobile } = useSidebar(); + const { t } = useTranslation(); + const userRoles = user?.roles.map((role) => role.key) || []; const menuItems: MenuItem[] = [ @@ -38,282 +58,193 @@ export const AppMenuTabs = () => { href: "/user-management/dashboard", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Overview", }, { label: "organizations", href: "/user-management/organizations", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, - { - label: "organizationAdmins", // Shortened for mobile - displayLabel: "organizationAdmins", // Full label for desktop + label: "organizationAdmins", href: "/user-management/organization_admins", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, { - label: "externalUsers", // Shortened for mobile - displayLabel: "externalUsers", // Full label for desktop + label: "externalUsers", href: "/user-management/external_users", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, { label: "dashboard", href: "/user-management/user_management-dashboard", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Overview", }, { label: "userManagement", href: "/user-management/user_management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Overview", }, - // { - // label: "userPositionApproval", - // href: "/user-management/user-position-approval", - // icon: , - // roles: ["admin", "organization_admin", "unit_admin"], - // permissions: ["can:activateEmployee"], - // isPrimary: true, - // }, - // { - // label: "All Records", - // displayLabel: "All Records", - // href: "/user-management/all-records", - // icon: , - // roles: ["admin", "organization_admin", "unit_admin"], - // permissions: ["can:canViewAllRecords"], - // isPrimary: true, - // }, { - label: "contentManagement", // Shortened for mobile - displayLabel: "contentManagement", // Full label for desktop + label: "contentManagement", href: "/user-management/content-management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { label: "webManagement", - displayLabel: "webManagement", href: "/user-management/web-management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Position", - displayLabel: "positionTypes", - href: "/user-management/position-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin", "super_admin"], - isPrimary: true, - }, - { - label: "migratedRecords", - displayLabel: "migratedRecords", - href: "/user-management/migrated-records-management", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "settings", - displayLabel: "settings", - href: "/user-management/organization-settings", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { label: "Bulk", - displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { - label: "Archive Users", - displayLabel: "Archive Users", - href: "/user-management/archive-users", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "Archived Organizations", - displayLabel: "Archived Organizations", - href: "/user-management/archived-organizations", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "Archive Users", - displayLabel: "Archive Users", - href: "/user-management/archives", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Archived Units & Positions", - displayLabel: "Archived Units & Positions", - href: "/user-management/archived", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Sector Reports", - displayLabel: "Sector Reports", - href: "/user-management/sector-reports", - icon: , - roles: ["unit_admin", "admin", "organization_admin"], - isPrimary: true, - }, - { - label: "activityLog", - href: "/user-management/activity_log", - icon: , - roles: ["super_admin"], - isPrimary: false, - }, - { - label: "setting", - href: "/user-management/settings", - icon: , - roles: ["super_admin"], - isPrimary: false, - }, - { - label: "Letter Template", - href: "/user-management/templates", + label: "Position", + href: "/user-management/position-management", icon: , - roles: ["super_admin"], - isPrimary: false, + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "settings", + href: "/user-management/organization-settings", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Configuration", }, { label: "Add Site", href: "/user-management/add-site", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Configuration", + }, + { + label: "migratedRecords", + href: "/user-management/migrated-records-management", + icon: , + roles: ["super_admin"], + group: "Records", + }, + { + label: "Sector Reports", + href: "/user-management/sector-reports", + icon: , + roles: ["unit_admin", "admin", "organization_admin"], + group: "Records", + }, + { + label: "Archive Users", + href: "/user-management/archive-users", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archived Organizations", + href: "/user-management/archived-organizations", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archive Users", + href: "/user-management/archives", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "Archived Units & Positions", + href: "/user-management/archived", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "activityLog", + href: "/user-management/activity_log", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "setting", + href: "/user-management/settings", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "Letter Template", + href: "/user-management/templates", + icon: , + roles: ["super_admin"], + group: "System", }, - - // { - // label: "branding.title", - // href: "/user-management/web-management/Branding/Branding", - // icon: , - // roles: ["super_admin"], - // isPrimary: true, - // }, ]; - const { permissions } = usePermissions(); - const { t } = useTranslation(); const filteredMenu = menuItems.filter((item) => - item?.roles?.some((r) => userRoles.includes(r)), - ); - const primaryMenuItems = filteredMenu.filter( - (item) => item.isPrimary !== false, - ); - const secondaryMenuItems = filteredMenu.filter( - (item) => item.isPrimary === false, + item.roles?.some((r) => userRoles.includes(r)), ); + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + return ( - // Sticky (not fixed) so it stays in flow: content below never needs a - // magic offset matching this bar's responsive height. top-16 keeps it - // pinned just below the fixed 64px header while scrolling. - // -mt-8 cancels the excess of 's in-flow h-24 wrapper over its 64px - // fixed header, so the bar sits flush under the header with no jump. - // shrink-0 is load-bearing: as a flex item with overflow-hidden this bar - // would otherwise be flex-squashed to zero height when the page overflows. -
- {/* Mobile View - Two separate rows */} -
- {/* Primary items row */} -
- {primaryMenuItems.map((item) => ( - - `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
+ <> + {GROUP_ORDER.map((group) => { + const items = filteredMenu.filter((item) => item.group === group); + if (items.length === 0) return null; - {/* Secondary items row (if any) */} - {secondaryMenuItems.length > 0 && ( -
- {secondaryMenuItems.map((item) => ( - - `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
- )} -
- - {/* Desktop View - Single row with all items */} -
-
- {filteredMenu.map((item) => ( - - `flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
-
-
+ return ( + + {group} + + + {items.map((item) => { + const label = t(`organization.${item.label}`, item.label); + return ( + + + setOpenMobile(false)} + > + {item.icon} + {label} + + + + ); + })} + + + + ); + })} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index 3b33519b6..25f8700e8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -1,23 +1,26 @@ -import { useLocation } from "react-router-dom"; -import { Outlet } from "react-router-dom"; +import { Link, Outlet, useLocation } from "react-router-dom"; import { AppMenuTabs } from "./AppMenuTabs"; -import { useSidebar } from "@/shared/common/ui/sidebar"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarInset, + SidebarRail, + SidebarTrigger, +} from "@/shared/common/ui/sidebar"; import Top from "@/record-management/components/common/Top"; import { useAuth } from "@/shared/context/AuthContext"; export const AppLayout = () => { const { pathname } = useLocation(); const isAuthPage = pathname === "/"; - const { toggleSidebar } = useSidebar(); const { user } = useAuth(); - + const userRoles = user?.roles?.map((role) => role.key) || []; const isSuperAdmin = userRoles.includes("super_admin"); - - // html/body/#root are `overflow: hidden` (index.css) — the host chrome - // scrolls inside FreightDashboardLayout. This subtree renders its own - // full-page layout instead, so it must be its own scroll container or - // nothing scrolls. Top/AppMenuTabs are `fixed`, unaffected by the scroller. + + // Standalone full-page scroll container for the auth screen — the host + // chrome is `overflow: hidden`, so this subtree must scroll itself. if (isAuthPage) { return (
@@ -27,17 +30,55 @@ export const AppLayout = () => { } return ( - // renders its own in-flow h-24 wrapper around the fixed 64px header, - // so flow already clears the header — no extra top padding here. - // AppMenuTabs is sticky and in flow, so content starts right below it. -
- - -
-
- + <> + {/* is a shared component rendered as a full-width `fixed` 64px bar, + so the sidebar is offset to start beneath it (top-16) rather than + owning the top-left corner. Collapses to an icon rail on desktop; on + mobile it's a Sheet drawer opened by the below (Top's + own burger is a module-nav dropdown, not the sidebar toggle). */} + + + + EDR +
+ + User Management + + + EDR Freight + +
+ +
+ + + + +
+ + + + {/* Top provides its own in-flow h-24 spacer clearing the fixed header. */} + {/* Mobile-only drawer opener — desktop shows the sidebar/rail directly. */} +
+ + + Menu +
-
-
+
+
+ +
+
+ + ); };