mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
style: revamp the backaoffice shell
This commit is contained in:
@@ -1,6 +1,24 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@edr/ui-common/theme.css" layer(theme);
|
@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 {
|
:root {
|
||||||
--freight-brand: #1B9E7A;
|
--freight-brand: #1B9E7A;
|
||||||
--freight-brand-dark: #15805F;
|
--freight-brand-dark: #15805F;
|
||||||
|
|||||||
@@ -1,77 +1,75 @@
|
|||||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Container,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
Paperclip,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
PackageOpen,
|
||||||
|
Paperclip,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
Container,
|
|
||||||
Package,
|
|
||||||
PackageOpen,
|
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
//TrainTrack,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
|
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
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 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 PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
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 RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
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 BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
|
||||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
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 TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
|
||||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
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 WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||||
|
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||||
|
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
title: "Main menu",
|
title: "Main menu",
|
||||||
mutedTitle: true,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
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 {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
Languages,
|
Languages,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquare,
|
|
||||||
Moon,
|
Moon,
|
||||||
|
Search,
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} 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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { PageMeta } from "./types";
|
import type { PageMeta } from "./types";
|
||||||
import "./FreightDashboardHeader.css";
|
|
||||||
|
|
||||||
export interface FreightDashboardHeaderProps {
|
export interface FreightDashboardHeaderProps {
|
||||||
pageMeta: PageMeta;
|
pageMeta: PageMeta;
|
||||||
@@ -26,8 +37,15 @@ export interface FreightDashboardHeaderProps {
|
|||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
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 = ({
|
const FreightDashboardHeader = ({
|
||||||
pageMeta,
|
pageMeta,
|
||||||
headerRight,
|
headerRight,
|
||||||
@@ -38,8 +56,11 @@ const FreightDashboardHeader = ({
|
|||||||
onLogout,
|
onLogout,
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
|
mobileOpened,
|
||||||
|
onToggleMobile,
|
||||||
}: FreightDashboardHeaderProps) => {
|
}: FreightDashboardHeaderProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const initials =
|
const initials =
|
||||||
userInitials ??
|
userInitials ??
|
||||||
(userName
|
(userName
|
||||||
@@ -50,194 +71,151 @@ const FreightDashboardHeader = ({
|
|||||||
.join("") ||
|
.join("") ||
|
||||||
"U");
|
"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 (
|
return (
|
||||||
<header className="fdh-root">
|
<AppShell.Header
|
||||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
withBorder={false}
|
||||||
<span className="fdh-eyebrow">
|
// Frosted glass: fully transparent background + backdrop blur, mirroring
|
||||||
{/* <span className="fdh-eyebrow-dot" />
|
// the portal AppLayout. Inline styles win over Mantine's cascade layer
|
||||||
Freight Backoffice */}
|
// (a Tailwind bg-transparent would lose to --mantine-color-body).
|
||||||
</span>
|
style={{
|
||||||
<Text
|
backdropFilter: "blur(14px)",
|
||||||
fw={700}
|
WebkitBackdropFilter: "blur(14px)",
|
||||||
truncate
|
border: "none",
|
||||||
style={{
|
boxShadow: "none",
|
||||||
fontSize: "20px",
|
background: "transparent",
|
||||||
lineHeight: 1.2,
|
}}
|
||||||
color: "#0f172a",
|
>
|
||||||
letterSpacing: "-0.4px",
|
<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 }}>
|
||||||
{pageMeta.title}
|
<Burger
|
||||||
</Text>
|
opened={mobileOpened}
|
||||||
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
|
onClick={onToggleMobile}
|
||||||
{pageMeta.subtitle}
|
hiddenFrom="sm"
|
||||||
</Text> */}
|
size="sm"
|
||||||
</Stack>
|
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">
|
{/* Right: search + actions + avatar */}
|
||||||
{enableThemeToggle && (
|
<Group gap={10} wrap="nowrap" align="center">
|
||||||
<Tooltip
|
{/* Search pill */}
|
||||||
label={theme === "dark" ? "Light mode" : "Dark mode"}
|
<Group
|
||||||
withArrow
|
gap={8}
|
||||||
openDelay={300}
|
align="center"
|
||||||
|
visibleFrom="md"
|
||||||
|
className="h-9 cursor-text rounded-full border border-edr-border bg-white px-3.5"
|
||||||
|
style={{ width: 260 }}
|
||||||
>
|
>
|
||||||
<button
|
<Search size={15} className="text-edr-muted" strokeWidth={1.8} />
|
||||||
type="button"
|
<Text size="sm" className="select-none text-edr-muted!">
|
||||||
className="fdh-icon-btn"
|
Search bookings, trains…
|
||||||
onClick={onToggleTheme}
|
</Text>
|
||||||
aria-label="Toggle theme"
|
</Group>
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
<Tooltip label="Language" withArrow openDelay={300}>
|
||||||
</button>
|
<UnstyledButton className={ISLAND} aria-label="Language">
|
||||||
|
<Languages size={17} strokeWidth={1.8} />
|
||||||
|
</UnstyledButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip label="Language" withArrow openDelay={300}>
|
<Tooltip label="Notifications" withArrow openDelay={300}>
|
||||||
<button type="button" className="fdh-icon-btn" aria-label="Language">
|
<Indicator
|
||||||
<Languages size={18} />
|
color="edr-accent"
|
||||||
</button>
|
size={8}
|
||||||
</Tooltip>
|
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}>
|
{enableThemeToggle && (
|
||||||
<button
|
<Tooltip
|
||||||
type="button"
|
label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
className="fdh-icon-btn"
|
withArrow
|
||||||
aria-label="Notifications"
|
openDelay={300}
|
||||||
>
|
>
|
||||||
<Bell size={18} />
|
<UnstyledButton
|
||||||
<span className="fdh-badge">5</span>
|
className={ISLAND}
|
||||||
</button>
|
onClick={onToggleTheme}
|
||||||
</Tooltip>
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun size={17} strokeWidth={1.8} />
|
||||||
|
) : (
|
||||||
|
<Moon size={17} strokeWidth={1.8} />
|
||||||
|
)}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="fdh-divider" />
|
{/* Avatar pill */}
|
||||||
|
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
|
||||||
<Menu
|
<Menu.Target>
|
||||||
position="bottom-end"
|
<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">
|
||||||
shadow="lg"
|
<Avatar radius="xl" size={28} color="edr-green.5">
|
||||||
radius="md"
|
<Text fw={700} fz={12} c="white">
|
||||||
width={240}
|
{initials}
|
||||||
opened={isUserMenuOpen}
|
</Text>
|
||||||
onOpen={() => setIsUserMenuOpen(true)}
|
</Avatar>
|
||||||
onClose={() => setIsUserMenuOpen(false)}
|
<Box visibleFrom="sm" style={{ minWidth: 0 }}>
|
||||||
>
|
<Text size="xs" fw={600} truncate className="text-edr-text!" style={{ lineHeight: 1.2, maxWidth: 120 }}>
|
||||||
<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" }}
|
|
||||||
>
|
|
||||||
{userName}
|
{userName}
|
||||||
</Text>
|
</Text>
|
||||||
{userEmail && (
|
<Text size="xs" truncate className="text-edr-muted!" style={{ lineHeight: 1.2, maxWidth: 120, fontSize: 11 }}>
|
||||||
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
|
{userEmail ?? "Administrator"}
|
||||||
{userEmail}
|
</Text>
|
||||||
</Text>
|
</Box>
|
||||||
)}
|
<ChevronDown size={15} className="text-edr-muted" strokeWidth={1.8} />
|
||||||
</Stack>
|
</UnstyledButton>
|
||||||
</Group>
|
</Menu.Target>
|
||||||
</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>
|
|
||||||
|
|
||||||
{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>
|
</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 { type ReactNode, useEffect, useState } from "react";
|
||||||
import { Box, Paper, MantineProvider } from "@mantine/core";
|
|
||||||
|
|
||||||
import FreightDashboardHeader from "./FreightDashboardHeader";
|
import FreightDashboardHeader from "./FreightDashboardHeader";
|
||||||
import FreightSidebar from "./FreightSidebar";
|
import FreightSidebar from "./FreightSidebar";
|
||||||
import { getPageMeta } from "./route-meta";
|
import { getPageMeta } from "./route-meta";
|
||||||
import type { SidebarSection } from "./types";
|
import type { SidebarSection } from "./types";
|
||||||
import { freightMantineTheme } from "@/theme/freight-brand";
|
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
const THEME_STORAGE_KEY = "edr-theme";
|
const THEME_STORAGE_KEY = "edr-theme";
|
||||||
|
const HEADER_HEIGHT = 64;
|
||||||
|
const NAVBAR_WIDTH = 280;
|
||||||
|
|
||||||
function getInitialTheme(): Theme {
|
function getInitialTheme(): Theme {
|
||||||
if (typeof window === "undefined") return "light";
|
if (typeof window === "undefined") return "light";
|
||||||
@@ -45,6 +47,9 @@ const FreightDashboardLayout = ({
|
|||||||
children,
|
children,
|
||||||
}: FreightDashboardLayoutProps) => {
|
}: FreightDashboardLayoutProps) => {
|
||||||
const pageMeta = getPageMeta(activeHref);
|
const pageMeta = getPageMeta(activeHref);
|
||||||
|
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>(() =>
|
const [theme, setTheme] = useState<Theme>(() =>
|
||||||
enableThemeToggle ? getInitialTheme() : "light",
|
enableThemeToggle ? getInitialTheme() : "light",
|
||||||
);
|
);
|
||||||
@@ -52,71 +57,62 @@ const FreightDashboardLayout = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enableThemeToggle) return;
|
if (!enableThemeToggle) return;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
if (theme === "dark") {
|
root.classList.toggle("dark", theme === "dark");
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
root.classList.remove("dark");
|
|
||||||
}
|
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
}, [theme, enableThemeToggle]);
|
}, [theme, enableThemeToggle]);
|
||||||
|
|
||||||
const toggleTheme = () =>
|
const toggleTheme = () =>
|
||||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||||
|
|
||||||
|
const navigate = (href: string) => {
|
||||||
|
closeMobile();
|
||||||
|
onNavigate?.(href);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AppShell
|
||||||
<MantineProvider theme={freightMantineTheme}>
|
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
|
<Box
|
||||||
style={{
|
className="overflow-y-auto bg-edr-bg"
|
||||||
display: "flex",
|
style={{ height: `calc(100dvh - ${HEADER_HEIGHT}px)` }}
|
||||||
height: "100dvh",
|
|
||||||
overflow: "hidden",
|
|
||||||
padding: "0px",
|
|
||||||
fontFamily: "'Outfit', var(--font-sans)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Box
|
{children}
|
||||||
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>
|
|
||||||
</Box>
|
</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 {
|
import {
|
||||||
type MouseEvent,
|
AppShell,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
NavLink,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ChevronDown, X } from "lucide-react";
|
||||||
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ChevronDown, Train } from "lucide-react";
|
|
||||||
import { Box, Stack, Text } from "@mantine/core";
|
|
||||||
|
|
||||||
import type { SidebarItem, SidebarSection } from "./types";
|
import type { SidebarItem, SidebarSection } from "./types";
|
||||||
import "./FreightSidebar.css";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
export interface FreightSidebarProps {
|
export interface FreightSidebarProps {
|
||||||
sections: SidebarSection[];
|
sections: SidebarSection[];
|
||||||
activeHref?: string;
|
activeHref?: string;
|
||||||
onNavigate?: (href: string) => void;
|
onNavigate?: (href: string) => void;
|
||||||
|
/** Close handler for the mobile drawer (X button, hidden on desktop). */
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarItemKey = (item: SidebarItem, parentKey: string) =>
|
const BRAND_LOGO = "/assets/logo.svg";
|
||||||
item.href ?? `${parentKey}::${item.label}`;
|
|
||||||
|
|
||||||
const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
|
// Active / inactive NavLink styling, expressed through the shared edr-* theme
|
||||||
items.flatMap((item) => {
|
// tokens (bridged into Tailwind in index.css). Items are pills floating on the
|
||||||
const hrefs: string[] = [];
|
// page background — the navbar itself has no surface of its own.
|
||||||
if (item.href) hrefs.push(item.href.toLowerCase());
|
const navClassNames = (active: boolean) =>
|
||||||
if (item.children?.length)
|
active
|
||||||
hrefs.push(...collectSidebarHrefs(item.children));
|
? {
|
||||||
return hrefs;
|
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[]) =>
|
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
||||||
sections.flatMap((section) => section.items);
|
`${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 = ({
|
const FreightSidebar = ({
|
||||||
sections,
|
sections,
|
||||||
activeHref,
|
activeHref,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
onClose,
|
||||||
}: FreightSidebarProps) => {
|
}: FreightSidebarProps) => {
|
||||||
const items = useMemo(() => flattenSectionItems(sections), [sections]);
|
|
||||||
const activePath = activeHref?.toLowerCase() ?? "";
|
const activePath = activeHref?.toLowerCase() ?? "";
|
||||||
|
|
||||||
const isHrefActive = useCallback(
|
const isHrefActive = useCallback(
|
||||||
(href: string) => {
|
(href: string) => {
|
||||||
const normalized = href.toLowerCase();
|
const normalized = href.toLowerCase();
|
||||||
return (
|
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||||
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[activePath],
|
[activePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
const branchContainsActive = useCallback(
|
const branchActive = useCallback(
|
||||||
(branch: SidebarItem[]) =>
|
(items: SidebarItem[]) => collectHrefs(items).some(isHrefActive),
|
||||||
collectSidebarHrefs(branch).some((href) => isHrefActive(href)),
|
|
||||||
[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 acc: Record<string, boolean> = {};
|
||||||
|
const walk = (items: SidebarItem[], parentKey: string) => {
|
||||||
const walk = (entries: SidebarItem[], parentKey: string) => {
|
items.forEach((item, i) => {
|
||||||
for (const entry of entries) {
|
if (!item.children?.length) return;
|
||||||
if (!entry.children?.length) continue;
|
const key = itemKey(parentKey, item, i);
|
||||||
const key = sidebarItemKey(entry, parentKey);
|
|
||||||
acc[key] =
|
acc[key] =
|
||||||
branchContainsActive(entry.children) ||
|
(item.href ? isHrefActive(item.href) : false) ||
|
||||||
(entry.href ? isHrefActive(entry.href) : false);
|
branchActive(item.children);
|
||||||
walk(entry.children, key);
|
walk(item.children, key);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
sections.forEach((section) => walk(section.items, section.title));
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, [activePath, branchContainsActive, isHrefActive, items]);
|
}, [sections, isHrefActive, branchActive]);
|
||||||
|
|
||||||
const [expanded, setExpanded] =
|
|
||||||
useState<Record<string, boolean>>(defaultExpanded);
|
|
||||||
|
|
||||||
|
const [openMap, setOpenMap] = useState(defaultOpen);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
setOpenMap((current) => ({ ...defaultOpen, ...current }));
|
||||||
}, [defaultExpanded]);
|
}, [defaultOpen]);
|
||||||
|
|
||||||
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
const toggle = useCallback(
|
||||||
if (onNavigate) {
|
(key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })),
|
||||||
event.preventDefault();
|
[],
|
||||||
onNavigate(href);
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExpanded = (key: string) => {
|
const renderItem = useCallback(
|
||||||
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
(item: SidebarItem, key: string): ReactNode => {
|
||||||
};
|
const hasChildren = !!item.children?.length;
|
||||||
|
|
||||||
const renderNavBranch = (
|
if (hasChildren) {
|
||||||
children: SidebarItem[],
|
const isLink = !!item.href;
|
||||||
depth: number,
|
const active =
|
||||||
parentKey: string,
|
(isLink ? isHrefActive(item.href!) : false) ||
|
||||||
): ReactNode =>
|
branchActive(item.children!);
|
||||||
children.map((child) => {
|
const isOpen = openMap[key] ?? false;
|
||||||
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!);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
// `opened` is controlled so a link-parent navigates on row click
|
||||||
<button
|
// without collapsing; the chevron is the only toggle affordance.
|
||||||
type="button"
|
<NavLink
|
||||||
className="fsb-group"
|
key={key}
|
||||||
data-active={groupActive}
|
label={item.label}
|
||||||
onClick={() => toggleExpanded(key)}
|
leftSection={item.icon}
|
||||||
>
|
active={active}
|
||||||
<span className="fsb-group-label">{child.label}</span>
|
opened={isOpen}
|
||||||
<ChevronDown
|
classNames={navClassNames(active)}
|
||||||
size={13}
|
onClick={ () => toggle(key)}
|
||||||
className="fsb-chevron"
|
rightSection={
|
||||||
style={{
|
<Box
|
||||||
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
component="span"
|
||||||
color: groupActive ? "#1B9E7A" : undefined,
|
role="button"
|
||||||
|
aria-label={isOpen ? "Collapse section" : "Expand section"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggle(key);
|
||||||
}}
|
}}
|
||||||
/>
|
className="flex cursor-pointer items-center"
|
||||||
</button>
|
>
|
||||||
{isOpen && (
|
<ChevronDown
|
||||||
<div className="fsb-branch">
|
size={16}
|
||||||
{renderNavBranch(child.children!, depth + 1, key)}
|
className="text-edr-muted transition-transform duration-200"
|
||||||
</div>
|
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;
|
if (!item.href) return null;
|
||||||
|
const active = isHrefActive(item.href);
|
||||||
const childActive = isHrefActive(child.href);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<NavLink
|
||||||
key={key}
|
key={key}
|
||||||
href={child.href}
|
label={item.label}
|
||||||
className="fsb-child"
|
leftSection={item.icon}
|
||||||
data-active={childActive}
|
active={active}
|
||||||
onClick={(e) => navigateTo(e, child.href!)}
|
classNames={navClassNames(active)}
|
||||||
>
|
onClick={() => onNavigate?.(item.href!)}
|
||||||
<span className="fsb-dot" />
|
/>
|
||||||
<span className="fsb-item-label">{child.label}</span>
|
|
||||||
</a>
|
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
[branchActive, isHrefActive, onNavigate, openMap, toggle],
|
||||||
|
);
|
||||||
|
|
||||||
const renderTopLevelItem = (item: SidebarItem) => {
|
const renderedSections = useMemo(
|
||||||
if (!item.href) return null;
|
() =>
|
||||||
|
sections.map((section) => (
|
||||||
const hasChildren = Boolean(item.children?.length);
|
<Box key={section.title}>
|
||||||
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>
|
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
fw={600}
|
tt="uppercase"
|
||||||
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
|
px="sm"
|
||||||
|
mb={6}
|
||||||
|
className={ "text-edr-muted!" }
|
||||||
|
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||||
>
|
>
|
||||||
Backoffice Console
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
<Stack gap={2}>
|
||||||
</div>
|
{section.items.map((item, i) =>
|
||||||
|
renderItem(item, itemKey(section.title, item, i)),
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)),
|
||||||
|
[renderItem, sections],
|
||||||
|
);
|
||||||
|
|
||||||
<nav className="fsb-nav">
|
return (
|
||||||
{sections.map((section) => (
|
<AppShell.Navbar
|
||||||
<div key={section.title}>
|
withBorder={false}
|
||||||
<div className="fsb-section-label">{section.title}</div>
|
// White surface + hairline right border, matching the portal AppLayout.
|
||||||
<Stack gap={3}>
|
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
|
||||||
{section.items.map((item) => renderTopLevelItem(item))}
|
// lose to the navbar's default --mantine-color-body).
|
||||||
</Stack>
|
style={{
|
||||||
</div>
|
backgroundColor: "var(--mantine-color-edr-card-6)",
|
||||||
))}
|
borderRight: "1px solid var(--mantine-color-edr-border-6)",
|
||||||
</nav>
|
}}
|
||||||
</Box>
|
>
|
||||||
|
{/* 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