Initial commit

This commit is contained in:
2026-07-01 17:49:49 +05:30
commit c36f2d5b1d
36 changed files with 12284 additions and 0 deletions

View File

@@ -0,0 +1,516 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AppBar,
Toolbar,
IconButton,
Box,
InputBase,
Badge,
Avatar,
Typography,
Menu,
MenuItem,
Divider,
ListItemIcon,
ListItemText,
Tooltip,
Button,
Stack,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
alpha,
InputAdornment
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
import ChatIcon from '@mui/icons-material/Chat';
import LogoutIcon from '@mui/icons-material/Logout';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
import AcUnitIcon from '@mui/icons-material/AcUnit';
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
import DoneAllIcon from '@mui/icons-material/DoneAll';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import SendIcon from '@mui/icons-material/Send';
import Logo from '@/components/Logo';
const RED = '#C01227';
const INITIAL_NOTIFICATIONS = [
{ id: 1, icon: LocalShippingOutlinedIcon, title: 'Linehaul MH-04-AX-8822 from Mumbai Hub arrived', time: '5 min ago', to: '/inbound', read: false },
{ id: 2, icon: QrCodeScannerIcon, title: 'Manifest #MNF-2940 sorted & sealed', time: '15 min ago', to: '/dispatch', read: false },
{ id: 3, icon: TwoWheelerIcon, title: 'Last-mile Miler Deepak went online', time: '30 min ago', to: '/dispatch', read: false },
{ id: 4, icon: AcUnitIcon, title: 'Cold Storage Zone C temperature stabilized at 4.2°C', time: '1 hr ago', to: '/inventory', read: true }
];
const MESSAGES = [
{ id: 1, name: 'Devendra (Gate Supervisor)', text: 'Jaipur vehicle is backing into Bay 4 now.', time: '3 min ago', initials: 'DS' },
{ id: 2, name: 'Suresh (Sorter Zone A)', text: 'Completed sorting for South Delhi batch. Ready to verify.', time: '12 min ago', initials: 'SS' },
{ id: 3, name: 'Neha (Last-mile Dispatcher)', text: 'We need 2 more milers for South Delhi route.', time: '45 min ago', initials: 'ND' }
];
const NOTIFICATION_DETAILS = {
1: {
title: 'Linehaul Arrival: Mumbai Hub',
desc: 'Linehaul vehicle MH-04-AX-8822 has checked in at gate terminal Bay 4. Manifest includes 480 mixed corporate shipments.',
stats: [
{ label: 'Carrier Plate', value: 'MH-04-AX-8822' },
{ label: 'Origin Node', value: 'Mumbai Hub (BOM-02)' },
{ label: 'Inbound Sorters', value: 'Allocated (Zone A & C)' },
{ label: 'Cold Chain items', value: '18 items binned' }
],
actionText: 'Go to Inbound Station',
to: '/inbound'
},
2: {
title: 'Sealed Outbound Manifest #MNF-2940',
desc: 'Sorting manifest #MNF-2940 has been closed, sealed, and audited by supervisor Suresh. Packages are loaded in transit cart.',
stats: [
{ label: 'Manifest ID', value: 'MNF-2940' },
{ label: 'Route Zone', value: 'West Delhi (Dwarka)' },
{ label: 'Packages Count', value: '42 items loaded' },
{ label: 'Seal ID Code', value: 'SEAL-DEL-98402' }
],
actionText: 'Go to Outbound Dispatch',
to: '/dispatch'
},
3: {
title: 'Miler Deepak Sharma Online',
desc: 'Last-mile EV miler Deepak Sharma logged into transit terminal app. Courier battery capacity 98% (Fully Charged).',
stats: [
{ label: 'Miler Name', value: 'Deepak Sharma' },
{ label: 'Vehicle Class', value: 'EV Two-wheeler' },
{ label: 'Capacity Limit', value: '35 kg' },
{ label: 'Assigned Route', value: 'South Delhi (Saket)' }
],
actionText: 'Go to Outbound Dispatch',
to: '/dispatch'
},
4: {
title: 'Cold Storage Compliance Logged',
desc: 'Sensor Zone C logged audit snapshot at 12:00 PM. Climate check parameters successfully verified at 4.2°C.',
stats: [
{ label: 'Chamber Area', value: 'Cold Zone Chamber C' },
{ label: 'Sensor Reading', value: '4.2°C (Target: 2-8°C)' },
{ label: 'Log ID', value: 'LOG-C-98248102' },
{ label: 'Status', value: 'Optimal Compliance' }
],
actionText: 'Go to Storage & Bins',
to: '/inventory'
}
};
const INITIAL_CHATS = {
1: {
name: 'Devendra (Gate Supervisor)',
initials: 'DS',
chat: [
{ sender: 'them', text: 'Jaipur vehicle is backing into Bay 4 now. Sorters are ready.', time: '11:20 AM' },
{ sender: 'me', text: 'Excellent. Please verify temperature logs for the cold container immediately upon opening.', time: '11:21 AM' },
{ sender: 'them', text: 'Got it, Rajesh. Handled by sorter Devendra.', time: '11:22 AM' }
]
},
2: {
name: 'Suresh (Sorter Zone A)',
initials: 'SS',
chat: [
{ sender: 'them', text: 'Completed sorting for South Delhi batch. Ready to verify.', time: '11:10 AM' },
{ sender: 'me', text: 'Have all exceptions been routed to Zone D?', time: '11:12 AM' },
{ sender: 'them', text: 'Yes, 3 damaged boxes are in Zone D. Rest are loaded in the manifest bags.', time: '11:15 AM' }
]
},
3: {
name: 'Neha (Last-mile Dispatcher)',
initials: 'ND',
chat: [
{ sender: 'them', text: 'We need 2 more milers for South Delhi route. Surge in fashion orders.', time: '10:40 AM' },
{ sender: 'me', text: 'Let me ping Deepak and Yashpal to see if they can start early shift.', time: '10:45 AM' },
{ sender: 'them', text: 'Deepak just logged online. Still need one more.', time: '11:05 AM' }
]
}
};
export default function Header({ onToggle }) {
const navigate = useNavigate();
const [account, setAccount] = useState(null);
const [notifAnchor, setNotifAnchor] = useState(null);
const [msgAnchor, setMsgAnchor] = useState(null);
// Dialog State
const [selectedNotif, setSelectedNotif] = useState(null);
const [selectedChatId, setSelectedChatId] = useState(null);
const [chats, setChats] = useState(INITIAL_CHATS);
const [typedMessage, setTypedMessage] = useState('');
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
const [search, setSearch] = useState('');
const unread = notifications.filter((n) => !n.read).length;
const openNotif = (e) => setNotifAnchor(e.currentTarget);
const closeNotif = () => setNotifAnchor(null);
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
const onNotifClick = (n) => {
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
closeNotif();
if (NOTIFICATION_DETAILS[n.id]) {
setSelectedNotif(NOTIFICATION_DETAILS[n.id]);
} else {
navigate(n.to);
}
};
const onMessageClick = (mId) => {
setMsgAnchor(null);
setSelectedChatId(mId);
};
const handleSendMessage = () => {
if (!typedMessage.trim() || !selectedChatId) return;
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setChats((prev) => {
const activeChat = prev[selectedChatId];
return {
...prev,
[selectedChatId]: {
...activeChat,
chat: [
...activeChat.chat,
{ sender: 'me', text: typedMessage, time: timeStr }
]
}
};
});
setTypedMessage('');
};
const submitSearch = (e) => {
e.preventDefault();
const q = search.trim();
if (q) navigate(`/routing?q=${encodeURIComponent(q)}`);
};
return (
<AppBar
position="fixed"
elevation={0}
sx={{
bgcolor: '#FFFFFF',
color: 'text.primary',
zIndex: (t) => t.zIndex.drawer + 1,
borderBottom: '1px solid',
borderColor: 'grey.200'
}}
>
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
<MenuIcon />
</IconButton>
{/* Brand wordmark — left side */}
<Box
onClick={() => navigate('/dashboard')}
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<Logo height={22} />
</Box>
<Box sx={{ flexGrow: 1 }} />
{/* Search */}
<Box
component="form"
onSubmit={submitSearch}
sx={{
display: { xs: 'none', sm: 'flex' },
alignItems: 'center',
bgcolor: 'grey.100',
borderRadius: 2,
px: 1.5,
py: 0.5,
width: { sm: 240, md: 320 },
border: '1px solid',
borderColor: 'grey.200',
'&:hover': { bgcolor: 'grey.200' }
}}
>
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} />
<InputBase
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Scan package, check destination…"
sx={{ fontSize: '0.875rem', flex: 1 }}
inputProps={{ 'aria-label': 'search' }}
/>
</Box>
<Tooltip title="Messages">
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
<Badge badgeContent={MESSAGES.length} color="error">
<ChatIcon />
</Badge>
</IconButton>
</Tooltip>
<Tooltip title="Notifications">
<IconButton color="inherit" onClick={openNotif}>
<Badge badgeContent={unread} color="error">
<NotificationsNoneIcon />
</Badge>
</IconButton>
</Tooltip>
<Box
onClick={(e) => setAccount(e.currentTarget)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
ml: 0.5,
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 2,
'&:hover': { bgcolor: 'grey.100' }
}}
>
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>RK</Avatar>
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Rajesh Kumar
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Delhi Hub Manager
</Typography>
</Box>
</Box>
{/* Notifications dropdown */}
<Menu
anchorEl={notifAnchor}
open={Boolean(notifAnchor)}
onClose={closeNotif}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Notifications
</Typography>
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}>
Mark all read
</Button>
</Stack>
<Divider />
{notifications.length === 0 && (
<MenuItem disabled>
<ListItemText primary="No notifications" />
</MenuItem>
)}
{notifications.map((n) => {
const Icon = n.icon;
return (
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
<ListItemIcon sx={{ mt: 0.25 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
<Icon fontSize="small" />
</Avatar>
</ListItemIcon>
<ListItemText
primary={n.title}
secondary={n.time}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
secondaryTypographyProps={{ fontSize: '0.75rem' }}
/>
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
</MenuItem>
);
})}
</Menu>
{/* Messages dropdown */}
<Menu
anchorEl={msgAnchor}
open={Boolean(msgAnchor)}
onClose={() => setMsgAnchor(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
Messages
</Typography>
<Divider />
{MESSAGES.map((m) => (
<MenuItem key={m.id} onClick={() => onMessageClick(m.id)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
<ListItemIcon sx={{ mt: 0.25 }}>
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
{m.initials}
</Avatar>
</ListItemIcon>
<ListItemText
primary={m.name}
secondary={m.text}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 700 }}
secondaryTypographyProps={{ fontSize: '0.8rem' }}
/>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, mt: 0.5, flexShrink: 0 }}>
{m.time}
</Typography>
</MenuItem>
))}
</Menu>
{/* Account dropdown */}
<Menu
anchorEl={account}
open={Boolean(account)}
onClose={() => setAccount(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
>
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout
</MenuItem>
</Menu>
</Toolbar>
{/* High Fidelity Notification Detail Dialog */}
<Dialog open={Boolean(selectedNotif)} onClose={() => setSelectedNotif(null)} fullWidth maxWidth="sm">
{selectedNotif && (
<>
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}>
{selectedNotif.title}
</DialogTitle>
<Divider />
<DialogContent sx={{ py: 3 }}>
<Typography variant="body1" sx={{ mb: 3 }}>
{selectedNotif.desc}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'text.secondary' }}>
Operational Details
</Typography>
<Grid container spacing={2}>
{selectedNotif.stats.map((stat, index) => (
<Grid size={{ xs: 6 }} key={index}>
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'grey.200' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontWeight: 600 }}>
{stat.label}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mt: 0.5 }}>
{stat.value}
</Typography>
</Box>
</Grid>
))}
</Grid>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button>
<Button
variant="contained"
endIcon={<ArrowForwardIcon fontSize="small" />}
onClick={() => {
setSelectedNotif(null);
navigate(selectedNotif.to);
}}
>
{selectedNotif.actionText}
</Button>
</DialogActions>
</>
)}
</Dialog>
{/* High Fidelity Chat Message Dialog */}
<Dialog open={Boolean(selectedChatId)} onClose={() => setSelectedChatId(null)} fullWidth maxWidth="xs">
{selectedChatId && chats[selectedChatId] && (
<>
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
{chats[selectedChatId].initials}
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{chats[selectedChatId].name}
</Typography>
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
{chats[selectedChatId].chat.map((msg, idx) => (
<Box
key={idx}
sx={{
alignSelf: msg.sender === 'me' ? 'flex-end' : 'flex-start',
maxWidth: '80%'
}}
>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: msg.sender === 'me' ? RED : '#FFFFFF',
color: msg.sender === 'me' ? '#FFFFFF' : 'text.primary',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
border: msg.sender === 'me' ? 'none' : '1px solid',
borderColor: 'grey.200'
}}
>
<Typography variant="body1">
{msg.text}
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
mt: 0.5,
textAlign: msg.sender === 'me' ? 'right' : 'left'
}}
>
{msg.time}
</Typography>
</Box>
))}
</Stack>
</DialogContent>
<Divider />
<DialogActions sx={{ p: 1.5 }}>
<TextField
fullWidth
size="small"
placeholder="Type your message..."
value={typedMessage}
onChange={(e) => setTypedMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={handleSendMessage} size="small" color="primary">
<SendIcon fontSize="small" />
</IconButton>
</InputAdornment>
)
}}
/>
</DialogActions>
</>
)}
</Dialog>
</AppBar>
);
}

View File

@@ -0,0 +1,310 @@
import { useState, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Drawer,
Box,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
Collapse,
Tooltip,
Toolbar
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import navItems from '@/menu/navItems';
import Logo from '@/components/Logo';
export const DRAWER_WIDTH = 240;
export const MINI_WIDTH = 72;
const BRAND_RED = '#C01227';
function NavLeaf({ item, open, active, depth = 0, onClick }) {
const Icon = item.icon;
const button = (
<ListItemButton
selected={active}
onClick={onClick}
sx={{
minHeight: 44,
my: 0.25,
mx: 1.25,
px: open ? 1.5 : 0,
justifyContent: open ? 'flex-start' : 'center',
borderRadius: '8px',
color: active ? BRAND_RED : 'text.primary',
transition: (theme) => theme.transitions.create(['background-color', 'color', 'padding'], {
duration: theme.transitions.duration.shorter,
}),
'& .MuiListItemIcon-root': {
color: active ? BRAND_RED : 'text.secondary',
minWidth: open ? 32 : 0,
justifyContent: 'center'
},
'&:hover': {
bgcolor: 'action.hover',
color: 'text.primary',
'& .MuiListItemIcon-root': { color: 'text.primary' }
},
'&.Mui-selected': {
bgcolor: alpha(BRAND_RED, 0.08),
color: BRAND_RED,
'& .MuiListItemIcon-root': { color: BRAND_RED },
'&:hover': { bgcolor: alpha(BRAND_RED, 0.12) }
}
}}
>
<ListItemIcon>
{depth > 0 && !Icon ? (
<FiberManualRecordIcon sx={{ fontSize: 6 }} />
) : Icon ? (
<Icon fontSize="small" />
) : null}
</ListItemIcon>
{open && (
<ListItemText
primary={item.title}
primaryTypographyProps={{
fontSize: '0.875rem',
fontWeight: active ? 600 : 500,
noWrap: true
}}
/>
)}
</ListItemButton>
);
if (!open) {
return (
<Tooltip title={item.title} placement="right" arrow disableInteractive>
{button}
</Tooltip>
);
}
return button;
}
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
const location = useLocation();
const navigate = useNavigate();
const expanded = open || isMobile;
const isActive = (url) => !!(url && location.pathname.startsWith(url));
// Memoize initial open state to prevent recalculation on every drawer toggle
const initialOpen = useMemo(() => {
return navItems
.flatMap((g) => g.items)
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
.map((i) => i.id);
}, [location.pathname]);
const [collapse, setCollapse] = useState(initialOpen);
const handleToggleCollapse = (id) => {
setCollapse((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const go = (url) => {
navigate(url);
if (isMobile) onMobileClose();
};
const sidebarContent = (
<Box
sx={{
bgcolor: 'background.paper',
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid',
borderColor: 'divider'
}}
>
{/* Top Branding Section */}
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
<Logo compact={!expanded} />
</Toolbar>
{/* Navigation Scroll Area */}
<Box
sx={{
overflowY: 'auto',
overflowX: 'hidden',
flexGrow: 1,
pb: 2,
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'action.hover',
borderRadius: 4,
},
'&:hover::-webkit-scrollbar-thumb': {
backgroundColor: 'action.focus',
}
}}
>
{navItems.map((grp) => (
<Box key={grp.group} sx={{ mt: 2.5 }}>
{expanded && (
<Typography
variant="overline"
sx={{
px: 2.5,
color: 'text.secondary',
fontWeight: 800,
fontSize: '0.6875rem',
letterSpacing: '0.08em',
display: 'block'
}}
>
{grp.group}
</Typography>
)}
<List disablePadding sx={{ mt: 0.5 }}>
{grp.items.map((item) => {
if (item.children) {
const opened = collapse.includes(item.id);
const childActive = item.children.some((c) => isActive(c.url));
const Icon = item.icon;
const headerButton = (
<ListItemButton
onClick={() => expanded ? handleToggleCollapse(item.id) : go(item.children[0].url)}
sx={{
minHeight: 44,
my: 0.25,
mx: 1.25,
px: expanded ? 1.5 : 0,
justifyContent: expanded ? 'flex-start' : 'center',
borderRadius: '8px',
color: 'text.primary',
bgcolor: childActive && !opened ? alpha(BRAND_RED, 0.04) : 'transparent',
'& .MuiListItemIcon-root': {
color: childActive ? BRAND_RED : 'text.secondary',
minWidth: expanded ? 32 : 0,
justifyContent: 'center'
},
'&:hover': {
bgcolor: 'action.hover',
'& .MuiListItemIcon-root': { color: 'text.primary' }
}
}}
>
<ListItemIcon>
<Icon fontSize="small" />
</ListItemIcon>
{expanded && (
<>
<ListItemText
primary={item.title}
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: childActive ? 600 : 500 }}
/>
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</>
)}
</ListItemButton>
);
return (
<Box key={item.id}>
{expanded ? headerButton : <Tooltip title={item.title} placement="right" arrow>{headerButton}</Tooltip>}
{expanded && (
<Collapse in={opened} timeout="auto" unmountOnExit>
<Box sx={{ mt: 0.25 }}>
{item.children.map((c) => (
<NavLeaf
key={c.id}
item={c}
open
depth={1}
active={isActive(c.url)}
onClick={() => go(c.url)}
/>
))}
</Box>
</Collapse>
)}
</Box>
);
}
return (
<NavLeaf
key={item.id}
item={item}
open={expanded}
active={isActive(item.url)}
onClick={() => go(item.url)}
/>
);
})}
</List>
</Box>
))}
</Box>
{/* Bottom Footer Branding */}
{expanded && (
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.default' }}>
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 600, display: 'block', lineHeight: 1.3 }}>
Hub Control Panel
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Doormile Logistics · v1.0
</Typography>
</Box>
)}
</Box>
);
if (isMobile) {
return (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onMobileClose}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
>
{sidebarContent}
</Drawer>
);
}
return (
<Drawer
variant="permanent"
sx={{
width: open ? DRAWER_WIDTH : MINI_WIDTH,
flexShrink: 0,
whiteSpace: 'nowrap',
'& .MuiDrawer-paper': {
width: open ? DRAWER_WIDTH : MINI_WIDTH,
border: 'none',
overflowX: 'hidden',
transition: (theme) => theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.standard,
}),
},
}}
open={open}
>
{sidebarContent}
</Drawer>
);
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Box, useMediaQuery } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import Header from './Header';
import Sidebar from './Sidebar';
export default function MainLayout() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
const [open, setOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const toggle = () => {
if (isMobile) setMobileOpen((p) => !p);
else setOpen((p) => !p);
};
return (
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
<Header onToggle={toggle} />
<Sidebar
open={open}
isMobile={isMobile}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<Box
component="main"
sx={{
flexGrow: 1,
minWidth: 0,
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
}}
>
<Box sx={{ height: 64, flexShrink: 0 }} />
<Box
sx={{
flexGrow: 1,
width: '100%',
maxWidth: '100%',
overflowX: 'hidden',
px: { xs: 1.5, sm: 2.5, md: 3.5 },
py: { xs: 2, sm: 2.5, md: 3 }
}}
>
<Outlet />
</Box>
</Box>
</Box>
);
}