style: revamp the backaoffice shell

This commit is contained in:
Nathnael
2026-06-20 09:25:23 +00:00
parent 9caf9bbd59
commit e4955c75de
7 changed files with 439 additions and 924 deletions

View File

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

View File

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

View File

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

View File

@@ -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<HTMLDivElement | null>(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 (
<header className="fdh-root">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<span className="fdh-eyebrow">
{/* <span className="fdh-eyebrow-dot" />
Freight Backoffice */}
</span>
<Text
fw={700}
truncate
style={{
fontSize: "20px",
lineHeight: 1.2,
color: "#0f172a",
letterSpacing: "-0.4px",
}}
>
{pageMeta.title}
</Text>
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle}
</Text> */}
</Stack>
<AppShell.Header
withBorder={false}
// Frosted glass: fully transparent background + backdrop blur, mirroring
// the portal AppLayout. Inline styles win over Mantine's cascade layer
// (a Tailwind bg-transparent would lose to --mantine-color-body).
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<Group h="100%" px={20} justify="space-between" wrap="nowrap">
{/* Left: burger (mobile) + page title */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
<Text fw={700} truncate className="text-edr-text!" fz={17} style={{ letterSpacing: "-0.02em", lineHeight: 1.2 }}>
{pageMeta.title}
</Text>
</Group>
<Group gap={10} wrap="nowrap">
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
{/* Right: search + actions + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Search pill */}
<Group
gap={8}
align="center"
visibleFrom="md"
className="h-9 cursor-text rounded-full border border-edr-border bg-white px-3.5"
style={{ width: 260 }}
>
<button
type="button"
className="fdh-icon-btn"
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<Search size={15} className="text-edr-muted" strokeWidth={1.8} />
<Text size="sm" className="select-none text-edr-muted!">
Search bookings, trains
</Text>
</Group>
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Language" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Language">
<Languages size={18} />
</button>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<Indicator
color="edr-accent"
size={8}
offset={6}
withBorder
aria-label="Unread notifications"
>
<UnstyledButton className={ISLAND} aria-label="Notifications">
<Bell size={17} strokeWidth={1.8} />
</UnstyledButton>
</Indicator>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<button
type="button"
className="fdh-icon-btn"
aria-label="Notifications"
>
<Bell size={18} />
<span className="fdh-badge">5</span>
</button>
</Tooltip>
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
>
<UnstyledButton
className={ISLAND}
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={17} strokeWidth={1.8} />
) : (
<Moon size={17} strokeWidth={1.8} />
)}
</UnstyledButton>
</Tooltip>
)}
<div className="fdh-divider" />
<Menu
position="bottom-end"
shadow="lg"
radius="md"
width={240}
opened={isUserMenuOpen}
onOpen={() => setIsUserMenuOpen(true)}
onClose={() => setIsUserMenuOpen(false)}
>
<Menu.Target>
<div className="fdh-user">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
<Text
size="sm"
fw={600}
truncate
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
>
{userName}
</Text>
<Text
size="xs"
truncate
style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
>
{userEmail ?? "Administrator"}
</Text>
</Stack>
<ChevronDown
size={16}
style={{
color: "#94a3b8",
flexShrink: 0,
transition: "transform 0.2s",
transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Group gap={10} wrap="nowrap">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text
size="sm"
fw={600}
truncate
style={{ color: "#0f172a" }}
>
{/* Avatar pill */}
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
<Menu.Target>
<UnstyledButton className="flex h-9 cursor-pointer items-center gap-2 rounded-full border border-edr-border bg-white py-0 pl-1 pr-2.5">
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<Box visibleFrom="sm" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate className="text-edr-text!" style={{ lineHeight: 1.2, maxWidth: 120 }}>
{userName}
</Text>
{userEmail && (
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
{userEmail}
</Text>
)}
</Stack>
</Group>
</Box>
<Menu.Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile");
}}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile#signature");
}}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => {
setIsUserMenuOpen(false);
onLogout?.();
}}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Text size="xs" truncate className="text-edr-muted!" style={{ lineHeight: 1.2, maxWidth: 120, fontSize: 11 }}>
{userEmail ?? "Administrator"}
</Text>
</Box>
<ChevronDown size={15} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
{headerRight}
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text size="sm" fw={600} truncate className="text-edr-text!">
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/dashboard/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
My signature
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => onLogout?.()}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
{headerRight}
</Group>
</Group>
</header>
</AppShell.Header>
);
};

View File

@@ -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<Theme>(() =>
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 (
<>
<MantineProvider theme={freightMantineTheme}>
<AppShell
layout="alt"
padding={0}
className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened}
onToggleMobile={toggleMobile}
/>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
<AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
assume (sidebar + header stay put, content scrolls beneath). */}
<Box
style={{
display: "flex",
height: "100dvh",
overflow: "hidden",
padding: "0px",
fontFamily: "'Outfit', var(--font-sans)",
}}
className="overflow-y-auto bg-edr-bg"
style={{ height: `calc(100dvh - ${HEADER_HEIGHT}px)` }}
>
<Box
style={{
display: "flex",
height: "100%",
minHeight: 0,
width: "100%",
}}
>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<Box
style={{
display: "flex",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: 1,
flexDirection: "column",
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
/>
{children}
</Box>
</Box>
{children}
</Box>
</MantineProvider>
</>
</AppShell.Main>
</AppShell>
);
};

View File

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

View File

@@ -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<string, boolean> = {};
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<Record<string, boolean>>(defaultExpanded);
}, [sections, isHrefActive, branchActive]);
const [openMap, setOpenMap] = useState(defaultOpen);
useEffect(() => {
setExpanded((current) => ({ ...defaultExpanded, ...current }));
}, [defaultExpanded]);
setOpenMap((current) => ({ ...defaultOpen, ...current }));
}, [defaultOpen]);
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, 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 (
<div key={key}>
<button
type="button"
className="fsb-group"
data-active={groupActive}
onClick={() => toggleExpanded(key)}
>
<span className="fsb-group-label">{child.label}</span>
<ChevronDown
size={13}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
color: groupActive ? "#1B9E7A" : undefined,
// `opened` is controlled so a link-parent navigates on row click
// without collapsing; the chevron is the only toggle affordance.
<NavLink
key={key}
label={item.label}
leftSection={item.icon}
active={active}
opened={isOpen}
classNames={navClassNames(active)}
onClick={ () => toggle(key)}
rightSection={
<Box
component="span"
role="button"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(key);
}}
/>
</button>
{isOpen && (
<div className="fsb-branch">
{renderNavBranch(child.children!, depth + 1, key)}
</div>
className="flex cursor-pointer items-center"
>
<ChevronDown
size={16}
className="text-edr-muted transition-transform duration-200"
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
/>
</Box>
}
>
{item.children!.map((child, i) =>
renderItem(child, itemKey(key, child, i)),
)}
</div>
</NavLink>
);
}
if (!child.href) return null;
const childActive = isHrefActive(child.href);
if (!item.href) return null;
const active = isHrefActive(item.href);
return (
<a
<NavLink
key={key}
href={child.href}
className="fsb-child"
data-active={childActive}
onClick={(e) => navigateTo(e, child.href!)}
>
<span className="fsb-dot" />
<span className="fsb-item-label">{child.label}</span>
</a>
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 (
<Box key={item.href}>
<Link to={item.href} className="fsb-item" data-active={isActive}>
{item.icon && <span className="fsb-icon">{item.icon}</span>}
<span className="fsb-item-label">{item.label}</span>
{hasChildren && (
<button
type="button"
className="fsb-chevron-btn"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleExpanded(item.href!);
}}
>
<ChevronDown
size={16}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
}}
/>
</button>
)}
</Link>
{hasChildren && isOpen && (
<div className="fsb-branch">
{renderNavBranch(item.children!, 0, item.href)}
</div>
)}
</Box>
);
};
return (
<Box
component="aside"
className="fsb-aside"
m="4px"
bg="edr-bg"
style={{
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<div className="fsb-brand">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
<Text
size="md"
fw={700}
style={{
letterSpacing: "-0.3px",
lineHeight: 1.2,
color: "#0f172a",
}}
>
EDR Freight
</Text>
const renderedSections = useMemo(
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
fw={600}
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
tt="uppercase"
px="sm"
mb={6}
className={ "text-edr-muted!" }
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
Backoffice Console
{section.title}
</Text>
</Stack>
</div>
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
)}
</Stack>
</Box>
)),
[renderItem, sections],
);
<nav className="fsb-nav">
{sections.map((section) => (
<div key={section.title}>
<div className="fsb-section-label">{section.title}</div>
<Stack gap={3}>
{section.items.map((item) => renderTopLevelItem(item))}
</Stack>
</div>
))}
</nav>
</Box>
return (
<AppShell.Navbar
withBorder={false}
// White surface + hairline right border, matching the portal AppLayout.
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
// lose to the navbar's default --mantine-color-body).
style={{
backgroundColor: "var(--mantine-color-edr-card-6)",
borderRight: "1px solid var(--mantine-color-edr-border-6)",
}}
>
{/* Brand — aligns with the 64px header for a continuous top edge */}
<Box className="flex h-16 shrink-0 items-center justify-between px-5">
<Group gap={10} wrap="nowrap">
<img
src={BRAND_LOGO}
alt="EDR Freight"
className="size-8 shrink-0 object-contain"
/>
<Box>
<Text
className="text-edr-primary! leading-tight"
fw={700}
fz={15}
style={{ letterSpacing: "-0.01em" }}
>
EDR Freight
</Text>
<Text
className="text-edr-muted!"
fz={10}
fw={500}
style={{ letterSpacing: "0.02em" }}
>
Backoffice Console
</Text>
</Box>
</Group>
{onClose && (
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
)}
</Box>
{/* Nav */}
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
<Stack gap="lg">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);
};