Files
edr-platform/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx

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;