From e4955c75deb6ded5e6496a810e962e8fb9ff578e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 20 Jun 2026 09:25:23 +0000 Subject: [PATCH] style: revamp the backaoffice shell --- apps/edr-freight-web/backoffice/index.css | 18 + apps/edr-freight-web/backoffice/src/App.tsx | 54 ++- .../layout/FreightDashboardHeader.css | 126 ------ .../layout/FreightDashboardHeader.tsx | 340 ++++++++-------- .../layout/FreightDashboardLayout.tsx | 110 +++-- .../src/components/layout/FreightSidebar.css | 333 --------------- .../src/components/layout/FreightSidebar.tsx | 382 +++++++++--------- 7 files changed, 439 insertions(+), 924 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css delete mode 100644 apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index 5dd466b0c..132d7d926 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,6 +1,24 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); +/* Bridge the central Mantine theme into Tailwind. freightMantineTheme + (createTheme) is the single source of truth; these just alias its generated + CSS variables so `bg-edr-*`, `text-edr-*`, `border-edr-*` utilities resolve + to the same tokens used by Mantine props. Mirrors edr-freight-web/portal. */ +@theme { + --color-edr-primary: var(--mantine-color-edr-green-5); + --color-edr-primary-dark: var(--mantine-color-edr-green-7); + --color-edr-bg: var(--mantine-color-edr-bg-6); + --color-edr-card: var(--mantine-color-edr-card-6); + --color-edr-border: var(--mantine-color-edr-border-6); + --color-edr-divider: var(--mantine-color-edr-divider-6); + --color-edr-text: var(--mantine-color-edr-text-6); + --color-edr-muted: var(--mantine-color-edr-muted-6); + --color-edr-soft: var(--mantine-color-edr-soft-6); + --color-edr-ink: var(--mantine-color-edr-ink-6); + --color-edr-accent: var(--mantine-color-edr-accent-6); +} + :root { --freight-brand: #1B9E7A; --freight-brand-dark: #15805F; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0c61cc445..4e079b26a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,77 +1,75 @@ -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Boxes, + Container, FileText, LayoutDashboard, LayoutGrid, Network, - Paperclip, + Package, PackageCheck, + PackageOpen, + Paperclip, Send, Settings, SlidersHorizontal, Train, Truck, - Container, - Package, - PackageOpen, Users, Wallet, - //TrainTrack, } from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import OverviewPage from "./pages/dashboard/OverviewPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import { RequirePermission } from "./components/auth/RequirePermission"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Main menu", - mutedTitle: true, items: [ { label: "Overview", diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css deleted file mode 100644 index 55410fe26..000000000 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css +++ /dev/null @@ -1,126 +0,0 @@ -/* ============================================================ - EDR Freight — Header styles - ============================================================ */ - -.fdh-root { - display: flex; - height: 80px; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 0 22px; -} - -/* eyebrow above the page title */ -.fdh-eyebrow { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: #1B9E7A; - margin-bottom: 3px; -} -.fdh-eyebrow-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: #2DBF95; - box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16); -} - -/* action icon buttons */ -.fdh-icon-btn { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 12px; - background: #f7f9fb; - border: 1px solid #eef1f4; - color: #475569; - cursor: pointer; - transition: all 160ms ease; -} -.fdh-icon-btn:hover { - background: #ffffff; - border-color: rgba(27, 158, 122, 0.28); - color: #1B9E7A; - box-shadow: 0 4px 12px -4px rgba(27, 158, 122, 0.28); - transform: translateY(-1px); -} -.fdh-icon-btn:active { - transform: translateY(0); -} - -/* notification badge */ -.fdh-badge { - position: absolute; - top: -5px; - right: -5px; - min-width: 17px; - height: 17px; - padding: 0 4px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 9px; - background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); - color: #ffffff; - font-size: 10px; - font-weight: 700; - line-height: 1; - border: 2px solid #ffffff; - box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45); -} - -.fdh-divider { - width: 1px; - height: 30px; - background: #e9eef3; - margin: 0 2px; -} - -/* user button */ -.fdh-user { - display: flex; - align-items: center; - gap: 10px; - padding: 5px 12px 5px 5px; - border-radius: 13px; - cursor: pointer; - border: 1px solid transparent; - transition: all 160ms ease; -} -.fdh-user:hover { - background: #f7f9fb; - border-color: #eef1f4; -} - -.fdh-avatar-ring { - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - padding: 2px; - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%); - box-shadow: 0 4px 10px -3px rgba(27, 158, 122, 0.4); -} -.fdh-avatar { - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border-radius: 50%; - background: linear-gradient(135deg, #1B9E7A 0%, #15805F 100%); - color: #ffffff; - font-size: 13px; - font-weight: 700; - letter-spacing: 0.3px; - border: 2px solid #ffffff; -} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index e626beb58..83c0fac44 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -1,20 +1,31 @@ -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { + AppShell, + Avatar, + Box, + Burger, + Divider, + Group, + Indicator, + Menu, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; import { Bell, ChevronDown, FileSignature, Languages, LogOut, - MessageSquare, Moon, + Search, Sun, User, } from "lucide-react"; -import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core"; +import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import type { PageMeta } from "./types"; -import "./FreightDashboardHeader.css"; export interface FreightDashboardHeaderProps { pageMeta: PageMeta; @@ -26,8 +37,15 @@ export interface FreightDashboardHeaderProps { onLogout?: () => void; theme: "light" | "dark"; onToggleTheme: () => void; + mobileOpened: boolean; + onToggleMobile: () => void; } +// Every header control is a consistent 36px frosted chip — same language as the +// portal AppLayout's floating "islands". +const ISLAND = + "flex size-9 shrink-0 items-center justify-center rounded-full border border-edr-border bg-white text-edr-text transition-colors hover:bg-[#F1F4F7]"; + const FreightDashboardHeader = ({ pageMeta, headerRight, @@ -38,8 +56,11 @@ const FreightDashboardHeader = ({ onLogout, theme, onToggleTheme, + mobileOpened, + onToggleMobile, }: FreightDashboardHeaderProps) => { const navigate = useNavigate(); + const initials = userInitials ?? (userName @@ -50,194 +71,151 @@ const FreightDashboardHeader = ({ .join("") || "U"); - const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const userMenuRef = useRef(null); - - useEffect(() => { - if (!isUserMenuOpen) return; - - const handlePointerDown = (event: MouseEvent) => { - if ( - userMenuRef.current && - !userMenuRef.current.contains(event.target as Node) - ) { - setIsUserMenuOpen(false); - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setIsUserMenuOpen(false); - }; - - document.addEventListener("mousedown", handlePointerDown); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("mousedown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [isUserMenuOpen]); - return ( -
- - - {/* - Freight Backoffice */} - - - {pageMeta.title} - - {/* - {pageMeta.subtitle} - */} - + + + {/* Left: burger (mobile) + page title */} + + + + {pageMeta.title} + + - - {enableThemeToggle && ( - + {/* Search pill */} + - + + + Search bookings, trains… + + + + + + + - )} - - - + + + + + + + - - - + {enableThemeToggle && ( + + + {theme === "dark" ? ( + + ) : ( + + )} + + + )} -
- - setIsUserMenuOpen(true)} - onClose={() => setIsUserMenuOpen(false)} - > - -
-
-
{initials}
-
- - - {userName} - - - {userEmail ?? "Administrator"} - - - -
-
- - - -
-
{initials}
-
- - + {/* Avatar pill */} + + + + + + {initials} + + + + {userName} - {userEmail && ( - - {userEmail} - - )} - - - - - } - onClick={() => { - setIsUserMenuOpen(false); - navigate("/dashboard/profile"); - }} - > - Profile - - } - onClick={() => { - setIsUserMenuOpen(false); - navigate("/dashboard/profile#signature"); - }} - > - My signature - - } - color="red" - onClick={() => { - setIsUserMenuOpen(false); - onLogout?.(); - }} - > - Logout - - - + + {userEmail ?? "Administrator"} + +
+ + + - {headerRight} + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + } + onClick={() => navigate("/dashboard/profile")} + > + Profile + + } + onClick={() => navigate("/dashboard/profile#signature")} + > + My signature + + + } + color="red" + onClick={() => onLogout?.()} + > + Logout + + +
+ + {headerRight} + -
+ ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx index 9ffc4753a..cd930d29b 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -1,14 +1,16 @@ +import { AppShell, Box } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { type ReactNode, useEffect, useState } from "react"; -import { Box, Paper, MantineProvider } from "@mantine/core"; import FreightDashboardHeader from "./FreightDashboardHeader"; import FreightSidebar from "./FreightSidebar"; import { getPageMeta } from "./route-meta"; import type { SidebarSection } from "./types"; -import { freightMantineTheme } from "@/theme/freight-brand"; type Theme = "light" | "dark"; const THEME_STORAGE_KEY = "edr-theme"; +const HEADER_HEIGHT = 64; +const NAVBAR_WIDTH = 280; function getInitialTheme(): Theme { if (typeof window === "undefined") return "light"; @@ -45,6 +47,9 @@ const FreightDashboardLayout = ({ children, }: FreightDashboardLayoutProps) => { const pageMeta = getPageMeta(activeHref); + const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = + useDisclosure(false); + const [theme, setTheme] = useState(() => enableThemeToggle ? getInitialTheme() : "light", ); @@ -52,71 +57,62 @@ const FreightDashboardLayout = ({ useEffect(() => { if (!enableThemeToggle) return; const root = document.documentElement; - if (theme === "dark") { - root.classList.add("dark"); - } else { - root.classList.remove("dark"); - } + root.classList.toggle("dark", theme === "dark"); window.localStorage.setItem(THEME_STORAGE_KEY, theme); }, [theme, enableThemeToggle]); const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark")); + const navigate = (href: string) => { + closeMobile(); + onNavigate?.(href); + }; + return ( - <> - + + + + + + + {/* Internal scroll keeps the fixed-viewport model the dashboard pages + assume (sidebar + header stay put, content scrolls beneath). */} - - - - - - - {children} - - + {children} - - + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css deleted file mode 100644 index b8d3e5cbe..000000000 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ /dev/null @@ -1,333 +0,0 @@ -/* ============================================================ - EDR Freight — Sidebar styles - Polished, professional navigation surface. - ============================================================ */ - -.fsb-aside { - height: 100%; - max-height: 100%; - width: 280px; - flex-shrink: 0; - border-radius: 12px; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* ---- Brand header ---- */ -.fsb-brand { - position: relative; - display: flex; - align-items: center; - gap: 12px; - height: 80px; - padding: 0 20px; - flex-shrink: 0; - border-bottom: 1px solid #f1f5f9; - overflow: hidden; -} - -.fsb-brand::after { - content: ""; - position: absolute; - inset: 0; - background: radial-gradient(120px 80px at 24px 18px, - rgba(34, 197, 94, 0.08), - transparent 70%); - pointer-events: none; -} - -.fsb-logo { - position: relative; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - border-radius: 13px; - background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 60%, #15805f 100%); - box-shadow: - 0 6px 16px -4px rgba(27, 158, 122, 0.45), - inset 0 1px 0 rgba(255, 255, 255, 0.25); - flex-shrink: 0; -} - -/* ---- Nav scroll region ---- */ -.fsb-nav { - flex: 1; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - padding: 14px 12px 12px; - display: flex; - flex-direction: column; - gap: 20px; -} - -.fsb-nav::-webkit-scrollbar { - width: 6px; -} - -.fsb-nav::-webkit-scrollbar-thumb { - background: #e2e8f0; - border-radius: 3px; -} - -.fsb-nav::-webkit-scrollbar-thumb:hover { - background: #cbd5e1; -} - -.fsb-nav::-webkit-scrollbar-track { - background: transparent; -} - -.fsb-section-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - color: #94a3b8; - padding: 0 12px; - margin-bottom: 6px; -} - -/* ---- Top-level item ---- */ -.fsb-item { - position: relative; - display: flex; - align-items: center; - gap: 11px; - width: 100%; - padding: 9px 12px; - border-radius: 11px; - cursor: pointer; - color: #475569; - font-size: 14px; - font-weight: 500; - line-height: 1.2; - text-align: left; - text-decoration: none; - transition: - background-color 160ms ease, - color 160ms ease, - box-shadow 160ms ease; -} - -.fsb-item:hover { - background-color: #f5f7fa; - color: #0f172a; -} - -.fsb-item[data-active="true"] { - background: linear-gradient(135deg, - rgba(34, 197, 94, 0.12) 0%, - rgba(27, 158, 122, 0.06) 100%); - color: #1b9e7a; - font-weight: 600; -} - -.fsb-item[data-active="true"]::before { - content: ""; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 22px; - border-radius: 0 4px 4px 0; - background: linear-gradient(180deg, #2dbf95 0%, #1b9e7a 100%); -} - -.fsb-item-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ---- Icon well ---- */ -.fsb-icon { - display: flex; - align-items: center; - justify-content: center; - border-radius: 9px; - flex-shrink: 0; - background: #f1f5f9; - color: #64748b; - transition: all 160ms ease; -} - -/* ---- Icon well ---- */ -.fsb-icon svg { - height: 24px; - width: 24px; -} - -.fsb-item:hover .fsb-icon { - background: #e6ebf1; - color: #334155; -} - -.fsb-item[data-active="true"] .fsb-icon { - background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%); - color: #ffffff; - box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45); -} - -.fsb-chevron { - flex-shrink: 0; - color: #94a3b8; - transition: transform 220ms ease; -} - -.fsb-chevron-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 0; - margin: 0; - border: none; - background: transparent; - cursor: pointer; - flex-shrink: 0; -} - -.fsb-chevron-btn:hover .fsb-chevron { - color: #64748b; -} - -/* ---- Nested branch ---- */ -.fsb-branch { - margin: 2px 0 2px 22px; - padding-left: 12px; - border-left: 1.5px solid #eef2f6; - display: flex; - flex-direction: column; - gap: 2px; -} - -/* group header (non-navigable) */ -.fsb-group { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 7px 10px; - border-radius: 8px; - cursor: pointer; - background: transparent; - transition: background-color 150ms ease; -} - -.fsb-group:hover { - background-color: #f5f7fa; -} - -.fsb-group-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.4px; - text-transform: uppercase; - color: #94a3b8; -} - -.fsb-group[data-active="true"] .fsb-group-label { - color: #1b9e7a; -} - -/* child leaf */ -.fsb-child { - position: relative; - display: flex; - align-items: center; - gap: 9px; - width: 100%; - padding: 7px 10px; - border-radius: 8px; - cursor: pointer; - color: #64748b; - font-size: 13px; - font-weight: 500; - text-decoration: none; - transition: - background-color 150ms ease, - color 150ms ease; -} - -.fsb-child:hover { - background-color: #f5f7fa; - color: #0f172a; -} - -.fsb-child[data-active="true"] { - color: #1b9e7a; - font-weight: 600; - background-color: rgba(27, 158, 122, 0.08); -} - -.fsb-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - background: #cbd5e1; - transition: all 150ms ease; -} - -.fsb-child:hover .fsb-dot { - background: #94a3b8; -} - -.fsb-child[data-active="true"] .fsb-dot { - background: #1b9e7a; - box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16); -} - -/* ---- Footer status card ---- */ -.fsb-footer { - flex-shrink: 0; - padding: 12px; - border-top: 1px solid #f1f5f9; -} - -.fsb-status { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 11px; - background: linear-gradient(135deg, #e7f8f2 0%, #f8fafc 100%); - border: 1px solid #e7f3ec; -} - -.fsb-pulse { - position: relative; - width: 9px; - height: 9px; - border-radius: 50%; - background: #2dbf95; - flex-shrink: 0; -} - -.fsb-pulse::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 50%; - background: #2dbf95; - animation: fsb-pulse 2s ease-out infinite; -} - -@keyframes fsb-pulse { - 0% { - transform: scale(1); - opacity: 0.6; - } - - 100% { - transform: scale(2.6); - opacity: 0; - } -} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 37a0c2a03..59efe49a7 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -1,264 +1,248 @@ import { - type MouseEvent, + AppShell, + Box, + Group, + NavLink, + ScrollArea, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; +import { ChevronDown, X } from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useMemo, useState, } from "react"; -import { ChevronDown, Train } from "lucide-react"; -import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; -import "./FreightSidebar.css"; -import { Link } from "react-router-dom"; export interface FreightSidebarProps { sections: SidebarSection[]; activeHref?: string; onNavigate?: (href: string) => void; + /** Close handler for the mobile drawer (X button, hidden on desktop). */ + onClose?: () => void; } -const sidebarItemKey = (item: SidebarItem, parentKey: string) => - item.href ?? `${parentKey}::${item.label}`; +const BRAND_LOGO = "/assets/logo.svg"; -const collectSidebarHrefs = (items: SidebarItem[]): string[] => - items.flatMap((item) => { - const hrefs: string[] = []; - if (item.href) hrefs.push(item.href.toLowerCase()); - if (item.children?.length) - hrefs.push(...collectSidebarHrefs(item.children)); - return hrefs; - }); +// Active / inactive NavLink styling, expressed through the shared edr-* theme +// tokens (bridged into Tailwind in index.css). Items are pills floating on the +// page background — the navbar itself has no surface of its own. +const navClassNames = (active: boolean) => + active + ? { + root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", + label: "text-edr-primary-dark! font-medium! text-sm!", + section: "text-edr-primary-dark!", + } + : { + root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", + label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", + section: "text-edr-text!", + }; -const flattenSectionItems = (sections: SidebarSection[]) => - sections.flatMap((section) => section.items); +const itemKey = (parentKey: string, item: SidebarItem, index: number) => + `${parentKey}/${item.href ?? item.label}/${index}`; + +const collectHrefs = (items: SidebarItem[]): string[] => + items.flatMap((item) => [ + ...(item.href ? [item.href.toLowerCase()] : []), + ...(item.children?.length ? collectHrefs(item.children) : []), + ]); const FreightSidebar = ({ sections, activeHref, onNavigate, + onClose, }: FreightSidebarProps) => { - const items = useMemo(() => flattenSectionItems(sections), [sections]); const activePath = activeHref?.toLowerCase() ?? ""; const isHrefActive = useCallback( (href: string) => { const normalized = href.toLowerCase(); - return ( - activePath === normalized || activePath.startsWith(`${normalized}/`) - ); + return activePath === normalized || activePath.startsWith(`${normalized}/`); }, [activePath], ); - const branchContainsActive = useCallback( - (branch: SidebarItem[]) => - collectSidebarHrefs(branch).some((href) => isHrefActive(href)), + const branchActive = useCallback( + (items: SidebarItem[]) => collectHrefs(items).some(isHrefActive), [isHrefActive], ); - const defaultExpanded = useMemo(() => { + // Branches containing the active route start expanded; manual toggles win + // afterwards (merge keeps user intent while still opening newly-active paths). + const defaultOpen = useMemo(() => { const acc: Record = {}; - - const walk = (entries: SidebarItem[], parentKey: string) => { - for (const entry of entries) { - if (!entry.children?.length) continue; - const key = sidebarItemKey(entry, parentKey); + const walk = (items: SidebarItem[], parentKey: string) => { + items.forEach((item, i) => { + if (!item.children?.length) return; + const key = itemKey(parentKey, item, i); acc[key] = - branchContainsActive(entry.children) || - (entry.href ? isHrefActive(entry.href) : false); - walk(entry.children, key); - } + (item.href ? isHrefActive(item.href) : false) || + branchActive(item.children); + walk(item.children, key); + }); }; - - for (const item of items) { - if (!item.children?.length) continue; - const key = item.href ?? item.label; - acc[key] = - activePath === key.toLowerCase() || - activePath.startsWith(`${key.toLowerCase()}/`) || - branchContainsActive(item.children); - walk(item.children, key); - } - + sections.forEach((section) => walk(section.items, section.title)); return acc; - }, [activePath, branchContainsActive, isHrefActive, items]); - - const [expanded, setExpanded] = - useState>(defaultExpanded); + }, [sections, isHrefActive, branchActive]); + const [openMap, setOpenMap] = useState(defaultOpen); useEffect(() => { - setExpanded((current) => ({ ...defaultExpanded, ...current })); - }, [defaultExpanded]); + setOpenMap((current) => ({ ...defaultOpen, ...current })); + }, [defaultOpen]); - const navigateTo = (event: MouseEvent, href: string) => { - if (onNavigate) { - event.preventDefault(); - onNavigate(href); - } - }; + const toggle = useCallback( + (key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })), + [], + ); - const toggleExpanded = (key: string) => { - setExpanded((current) => ({ ...current, [key]: !current[key] })); - }; + const renderItem = useCallback( + (item: SidebarItem, key: string): ReactNode => { + const hasChildren = !!item.children?.length; - const renderNavBranch = ( - children: SidebarItem[], - depth: number, - parentKey: string, - ): ReactNode => - children.map((child) => { - const key = sidebarItemKey(child, parentKey); - const isGroup = Boolean(child.children?.length) && !child.href; - - if (isGroup) { - const isOpen = expanded[key] ?? false; - const groupActive = branchContainsActive(child.children!); + if (hasChildren) { + const isLink = !!item.href; + const active = + (isLink ? isHrefActive(item.href!) : false) || + branchActive(item.children!); + const isOpen = openMap[key] ?? false; return ( -
- - {isOpen && ( -
- {renderNavBranch(child.children!, depth + 1, key)} -
+ className="flex cursor-pointer items-center" + > + + + } + > + {item.children!.map((child, i) => + renderItem(child, itemKey(key, child, i)), )} -
+ ); } - if (!child.href) return null; - - const childActive = isHrefActive(child.href); + if (!item.href) return null; + const active = isHrefActive(item.href); return ( - navigateTo(e, child.href!)} - > - - {child.label} - + label={item.label} + leftSection={item.icon} + active={active} + classNames={navClassNames(active)} + onClick={() => onNavigate?.(item.href!)} + /> ); - }); + }, + [branchActive, isHrefActive, onNavigate, openMap, toggle], + ); - const renderTopLevelItem = (item: SidebarItem) => { - if (!item.href) return null; - - const hasChildren = Boolean(item.children?.length); - const itemHref = item.href.toLowerCase(); - const childActive = hasChildren - ? branchContainsActive(item.children!) - : false; - const isCurrentItem = hasChildren - ? activePath === itemHref - : isHrefActive(itemHref); - const isActive = isCurrentItem || childActive; - const isOpen = expanded[item.href] ?? false; - - return ( - - - {item.icon && {item.icon}} - {item.label} - {hasChildren && ( - - )} - - - {hasChildren && isOpen && ( -
- {renderNavBranch(item.children!, 0, item.href)} -
- )} -
- ); - }; - - return ( - -
-
- -
- - - EDR Freight - + const renderedSections = useMemo( + () => + sections.map((section) => ( + - Backoffice Console + {section.title} - -
+ + {section.items.map((item, i) => + renderItem(item, itemKey(section.title, item, i)), + )} + +
+ )), + [renderItem, sections], + ); - - + return ( + + {/* Brand — aligns with the 64px header for a continuous top edge */} + + + EDR Freight + + + EDR Freight + + + Backoffice Console + + + + {onClose && ( + + + + )} + + + {/* Nav */} + + {renderedSections} + + ); };