mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: revamp the backaoffice shell
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user