mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
279 lines
7.9 KiB
TypeScript
279 lines
7.9 KiB
TypeScript
import {
|
|
type MouseEvent,
|
|
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";
|
|
|
|
export interface FreightSidebarProps {
|
|
sections: SidebarSection[];
|
|
activeHref?: string;
|
|
onNavigate?: (href: string) => void;
|
|
}
|
|
|
|
const sidebarItemKey = (item: SidebarItem, parentKey: string) =>
|
|
item.href ?? `${parentKey}::${item.label}`;
|
|
|
|
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;
|
|
});
|
|
|
|
const flattenSectionItems = (sections: SidebarSection[]) =>
|
|
sections.flatMap((section) => section.items);
|
|
|
|
const FreightSidebar = ({
|
|
sections,
|
|
activeHref,
|
|
onNavigate,
|
|
}: 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}/`)
|
|
);
|
|
},
|
|
[activePath],
|
|
);
|
|
|
|
const branchContainsActive = useCallback(
|
|
(branch: SidebarItem[]) =>
|
|
collectSidebarHrefs(branch).some((href) => isHrefActive(href)),
|
|
[isHrefActive],
|
|
);
|
|
|
|
const defaultExpanded = 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);
|
|
acc[key] =
|
|
branchContainsActive(entry.children) ||
|
|
(entry.href ? isHrefActive(entry.href) : false);
|
|
walk(entry.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);
|
|
}
|
|
|
|
return acc;
|
|
}, [activePath, branchContainsActive, isHrefActive, items]);
|
|
|
|
const [expanded, setExpanded] =
|
|
useState<Record<string, boolean>>(defaultExpanded);
|
|
|
|
useEffect(() => {
|
|
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
|
}, [defaultExpanded]);
|
|
|
|
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
|
if (onNavigate) {
|
|
event.preventDefault();
|
|
onNavigate(href);
|
|
}
|
|
};
|
|
|
|
const toggleExpanded = (key: string) => {
|
|
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
|
};
|
|
|
|
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!);
|
|
|
|
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,
|
|
}}
|
|
/>
|
|
</button>
|
|
{isOpen && (
|
|
<div className="fsb-branch">
|
|
{renderNavBranch(child.children!, depth + 1, key)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!child.href) return null;
|
|
|
|
const childActive = isHrefActive(child.href);
|
|
|
|
return (
|
|
<a
|
|
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>
|
|
);
|
|
});
|
|
|
|
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}>
|
|
<a
|
|
href={item.href}
|
|
className="fsb-item"
|
|
data-active={isActive}
|
|
onClick={(e) => {
|
|
if (hasChildren) {
|
|
setExpanded((current) => ({
|
|
...current,
|
|
[item.href!]: true,
|
|
}));
|
|
}
|
|
navigateTo(e, item.href!);
|
|
}}
|
|
>
|
|
{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>
|
|
)}
|
|
</a>
|
|
|
|
{hasChildren && isOpen && (
|
|
<div className="fsb-branch">
|
|
{renderNavBranch(item.children!, 0, item.href)}
|
|
</div>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Box component="aside" className="fsb-aside">
|
|
<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
|
|
size="xs"
|
|
fw={600}
|
|
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
|
|
>
|
|
Backoffice Console
|
|
</Text>
|
|
</Stack>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<div className="fsb-footer">
|
|
<div className="fsb-status">
|
|
<span className="fsb-pulse" />
|
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
|
<Text size="xs" fw={600} style={{ color: "#15805F", lineHeight: 1.3 }}>
|
|
All systems operational
|
|
</Text>
|
|
<Text size="10px" style={{ color: "#94a3b8", lineHeight: 1.3 }}>
|
|
EDR Platform · v1.0
|
|
</Text>
|
|
</Stack>
|
|
</div>
|
|
</div>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default FreightSidebar;
|