330 lines
9.7 KiB
TypeScript
330 lines
9.7 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import {
|
|
Box,
|
|
Collapse,
|
|
Drawer,
|
|
IconButton,
|
|
List,
|
|
ListItem,
|
|
ListItemButton,
|
|
ListItemIcon,
|
|
ListItemText,
|
|
Toolbar,
|
|
Tooltip,
|
|
} from '@mui/material';
|
|
import {
|
|
Dashboard as DashboardIcon,
|
|
DirectionsCar,
|
|
Build,
|
|
People,
|
|
Air,
|
|
CalendarMonth,
|
|
MenuBook,
|
|
AdminPanelSettings,
|
|
Settings,
|
|
Menu as MenuIcon,
|
|
ExpandMore,
|
|
ExpandLess,
|
|
} from '@mui/icons-material';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { vehiclesApi } from '../../services/vehicles';
|
|
|
|
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
|
|
|
interface SubItem {
|
|
text: string;
|
|
path: string;
|
|
}
|
|
|
|
interface NavigationItem {
|
|
text: string;
|
|
icon: JSX.Element;
|
|
path: string;
|
|
subItems?: SubItem[];
|
|
}
|
|
|
|
const kalenderSubItems: SubItem[] = [
|
|
{ text: 'Veranstaltungen', path: '/kalender?tab=0' },
|
|
{ text: 'Fahrzeugbuchungen', path: '/kalender?tab=1' },
|
|
];
|
|
|
|
const adminSubItems: SubItem[] = [
|
|
{ text: 'Services', path: '/admin?tab=0' },
|
|
{ text: 'System', path: '/admin?tab=1' },
|
|
{ text: 'Benutzer', path: '/admin?tab=2' },
|
|
{ text: 'Broadcast', path: '/admin?tab=3' },
|
|
{ text: 'Banner', path: '/admin?tab=4' },
|
|
{ text: 'Wartung', path: '/admin?tab=5' },
|
|
];
|
|
|
|
const baseNavigationItems: NavigationItem[] = [
|
|
{
|
|
text: 'Dashboard',
|
|
icon: <DashboardIcon />,
|
|
path: '/dashboard',
|
|
},
|
|
{
|
|
text: 'Kalender',
|
|
icon: <CalendarMonth />,
|
|
path: '/kalender',
|
|
subItems: kalenderSubItems,
|
|
},
|
|
{
|
|
text: 'Fahrzeuge',
|
|
icon: <DirectionsCar />,
|
|
path: '/fahrzeuge',
|
|
},
|
|
{
|
|
text: 'Ausrüstung',
|
|
icon: <Build />,
|
|
path: '/ausruestung',
|
|
},
|
|
{
|
|
text: 'Mitglieder',
|
|
icon: <People />,
|
|
path: '/mitglieder',
|
|
},
|
|
{
|
|
text: 'Atemschutz',
|
|
icon: <Air />,
|
|
path: '/atemschutz',
|
|
},
|
|
{
|
|
text: 'Wissen',
|
|
icon: <MenuBook />,
|
|
path: '/wissen',
|
|
},
|
|
];
|
|
|
|
const adminItem: NavigationItem = {
|
|
text: 'Admin',
|
|
icon: <AdminPanelSettings />,
|
|
path: '/admin',
|
|
subItems: adminSubItems,
|
|
};
|
|
|
|
const adminSettingsItem: NavigationItem = {
|
|
text: 'Einstellungen',
|
|
icon: <Settings />,
|
|
path: '/admin/settings',
|
|
};
|
|
|
|
interface SidebarProps {
|
|
mobileOpen: boolean;
|
|
onMobileClose: () => void;
|
|
}
|
|
|
|
function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { sidebarCollapsed, toggleSidebar } = useLayout();
|
|
const { user } = useAuth();
|
|
|
|
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
|
|
|
// Fetch vehicle list for dynamic dropdown sub-items
|
|
const { data: vehicleList } = useQuery({
|
|
queryKey: ['vehicles', 'sidebar'],
|
|
queryFn: () => vehiclesApi.getAll(),
|
|
staleTime: 2 * 60 * 1000,
|
|
});
|
|
|
|
const vehicleSubItems: SubItem[] = useMemo(
|
|
() =>
|
|
(vehicleList ?? []).map((v) => ({
|
|
text: v.bezeichnung ?? v.kurzname,
|
|
path: `/fahrzeuge/${v.id}`,
|
|
})),
|
|
[vehicleList],
|
|
);
|
|
|
|
const navigationItems = useMemo((): NavigationItem[] => {
|
|
const fahrzeugeItem: NavigationItem = {
|
|
text: 'Fahrzeuge',
|
|
icon: <DirectionsCar />,
|
|
path: '/fahrzeuge',
|
|
subItems: vehicleSubItems.length > 0 ? vehicleSubItems : undefined,
|
|
};
|
|
const items = baseNavigationItems.map((item) =>
|
|
item.path === '/fahrzeuge' ? fahrzeugeItem : item,
|
|
);
|
|
return isAdmin ? [...items, adminItem, adminSettingsItem] : items;
|
|
}, [isAdmin, vehicleSubItems]);
|
|
|
|
// Expand state for items with sub-items — auto-expand when route matches
|
|
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
|
|
|
const isExpanded = (item: NavigationItem) => {
|
|
if (expandedItems[item.path] !== undefined) return expandedItems[item.path];
|
|
// Auto-expand when the current route matches
|
|
return location.pathname === item.path || location.pathname.startsWith(item.path + '/');
|
|
};
|
|
|
|
const toggleExpand = (path: string) => {
|
|
setExpandedItems((prev) => ({ ...prev, [path]: !isExpanded({ path } as NavigationItem) }));
|
|
};
|
|
|
|
const handleNavigation = (path: string) => {
|
|
navigate(path);
|
|
onMobileClose();
|
|
};
|
|
|
|
const drawerContent = (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
<Toolbar />
|
|
<Box sx={{ display: 'flex', justifyContent: sidebarCollapsed ? 'center' : 'flex-end', px: 1, py: 0.5 }}>
|
|
<IconButton onClick={toggleSidebar} aria-label="Sidebar umschalten">
|
|
<MenuIcon />
|
|
</IconButton>
|
|
</Box>
|
|
<List sx={{ flex: 1 }}>
|
|
{navigationItems.map((item) => {
|
|
const isActive = location.pathname === item.path;
|
|
const hasSubItems = item.subItems && item.subItems.length > 0;
|
|
const expanded = hasSubItems && isExpanded(item);
|
|
return (
|
|
<Box key={item.text}>
|
|
<ListItem disablePadding>
|
|
<Tooltip
|
|
title={item.text}
|
|
placement="right"
|
|
arrow
|
|
disableHoverListener={!sidebarCollapsed}
|
|
>
|
|
<ListItemButton
|
|
selected={isActive}
|
|
onClick={() => handleNavigation(item.path)}
|
|
aria-label={`Zu ${item.text} navigieren`}
|
|
sx={{
|
|
justifyContent: sidebarCollapsed ? 'center' : 'initial',
|
|
'&.Mui-selected': {
|
|
backgroundColor: 'primary.light',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.main',
|
|
},
|
|
'& .MuiListItemIcon-root': {
|
|
color: 'primary.contrastText',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<ListItemIcon
|
|
sx={{
|
|
color: isActive ? 'inherit' : 'text.secondary',
|
|
minWidth: sidebarCollapsed ? 0 : undefined,
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{item.icon}
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={item.text}
|
|
sx={{
|
|
display: sidebarCollapsed ? 'none' : 'block',
|
|
}}
|
|
/>
|
|
{hasSubItems && !sidebarCollapsed && (
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleExpand(item.path);
|
|
}}
|
|
sx={{ color: isActive ? 'inherit' : 'text.secondary' }}
|
|
>
|
|
{expanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
|
</IconButton>
|
|
)}
|
|
</ListItemButton>
|
|
</Tooltip>
|
|
</ListItem>
|
|
{hasSubItems && !sidebarCollapsed && (
|
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
|
<List disablePadding>
|
|
{item.subItems!.map((sub) => {
|
|
const isSubActive = location.pathname + location.search === sub.path;
|
|
return (
|
|
<ListItemButton
|
|
key={sub.path}
|
|
onClick={() => handleNavigation(sub.path)}
|
|
selected={isSubActive}
|
|
sx={{
|
|
pl: 4,
|
|
py: 0.5,
|
|
'&.Mui-selected': {
|
|
backgroundColor: 'primary.light',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.main',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<ListItemText
|
|
primary={sub.text}
|
|
primaryTypographyProps={{ variant: 'body2' }}
|
|
/>
|
|
</ListItemButton>
|
|
);
|
|
})}
|
|
</List>
|
|
</Collapse>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</List>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* Mobile drawer */}
|
|
<Drawer
|
|
variant="temporary"
|
|
open={mobileOpen}
|
|
onClose={onMobileClose}
|
|
ModalProps={{
|
|
keepMounted: true, // Better mobile performance
|
|
}}
|
|
sx={{
|
|
display: { xs: 'block', sm: 'none' },
|
|
'& .MuiDrawer-paper': {
|
|
boxSizing: 'border-box',
|
|
width: DRAWER_WIDTH,
|
|
},
|
|
}}
|
|
aria-label="Mobile Navigation"
|
|
>
|
|
{drawerContent}
|
|
</Drawer>
|
|
|
|
{/* Desktop drawer */}
|
|
<Drawer
|
|
variant="permanent"
|
|
sx={{
|
|
display: { xs: 'none', sm: 'block' },
|
|
width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH,
|
|
flexShrink: 0,
|
|
'& .MuiDrawer-paper': {
|
|
width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH,
|
|
boxSizing: 'border-box',
|
|
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
|
|
overflowX: 'hidden',
|
|
},
|
|
}}
|
|
open
|
|
aria-label="Desktop Navigation"
|
|
>
|
|
{drawerContent}
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default Sidebar;
|