@@ -1,242 +1,79 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Box,
|
||||
InputBase,
|
||||
Avatar,
|
||||
Typography,
|
||||
Stack,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
ListItemIcon,
|
||||
Popper,
|
||||
Paper,
|
||||
ClickAwayListener,
|
||||
CircularProgress,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import { Settings, LogOut } from 'lucide-react';
|
||||
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
|
||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { fetchClients, fetchUsers } from '@/utils/apiClient';
|
||||
import { toClient } from '@/utils/mappers';
|
||||
import { fetchUsers } from '@/utils/apiClient';
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
export default function Header({ onToggle }) {
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Read user from localStorage
|
||||
|
||||
let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 };
|
||||
try {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
storedUserObj = JSON.parse(storedUser);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
if (storedUser) storedUserObj = JSON.parse(storedUser);
|
||||
} catch (e) {}
|
||||
|
||||
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
|
||||
|
||||
const displayRole = storedUserObj.role === 'admin' ? 'Administrator' :
|
||||
const displayRole = storedUserObj.role === 'admin' ? 'Administrator' :
|
||||
storedUserObj.role === 'manager' ? 'Manager' :
|
||||
storedUserObj.role === 'executive' ? 'Executive' : 'Operations Admin';
|
||||
const displayInitial = activeUserName.charAt(0).toUpperCase();
|
||||
|
||||
const searchRef = useRef(null);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loadedClients, setLoadedClients] = useState(false);
|
||||
const [loadingClients, setLoadingClients] = useState(false);
|
||||
const [openResults, setOpenResults] = useState(false);
|
||||
|
||||
// Fetch users to get the real name if the login payload didn't have it
|
||||
useEffect(() => {
|
||||
fetchUsers().then((users) => {
|
||||
if (storedUserObj.email) {
|
||||
// Strictly use email to match, as stored ID might belong to the doormile_auth table instead of appusers table
|
||||
const matchingUser = users.find(u => u.email === storedUserObj.email);
|
||||
if (matchingUser && matchingUser.first_name) {
|
||||
setActiveUserName(matchingUser.first_name);
|
||||
// Also update localStorage so it's fresh
|
||||
const updatedUser = { ...storedUserObj, name: matchingUser.first_name };
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
localStorage.setItem('user', JSON.stringify({ ...storedUserObj, name: matchingUser.first_name }));
|
||||
}
|
||||
}
|
||||
}).catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const ensureClients = () => {
|
||||
if (loadedClients || loadingClients) return;
|
||||
setLoadingClients(true);
|
||||
fetchClients()
|
||||
.then((points) => setClients(points.map(toClient)))
|
||||
.catch(() => {})
|
||||
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
||||
};
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const results = q
|
||||
? clients.filter((c) => [c.name, c.city, c.businessType, c.phone].join(' ').toLowerCase().includes(q)).slice(0, 6)
|
||||
: [];
|
||||
|
||||
const onSearchChange = (e) => {
|
||||
setSearch(e.target.value);
|
||||
ensureClients();
|
||||
setOpenResults(true);
|
||||
};
|
||||
|
||||
const goToClients = (term) => {
|
||||
navigate(`/tenants?q=${encodeURIComponent(term)}`);
|
||||
setSearch('');
|
||||
setOpenResults(false);
|
||||
};
|
||||
|
||||
const submitSearch = (e) => {
|
||||
e.preventDefault();
|
||||
const term = search.trim();
|
||||
if (term) goToClients(term);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{ bgcolor: '#fff', color: 'grey.800', zIndex: (t) => t.zIndex.drawer + 1, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<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>
|
||||
|
||||
<Box
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<Logo height={22} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
|
||||
<Box
|
||||
ref={searchRef}
|
||||
component="form"
|
||||
onSubmit={submitSearch}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
'&:hover': { bgcolor: 'grey.200' },
|
||||
'&:focus-within': { bgcolor: 'grey.200' }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'grey.500' }} />
|
||||
<InputBase
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
|
||||
placeholder="Search clients…"
|
||||
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: 'grey.500' } }}
|
||||
inputProps={{ 'aria-label': 'search' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Popper
|
||||
open={openResults && !!q}
|
||||
anchorEl={searchRef.current}
|
||||
placement="bottom-start"
|
||||
style={{ zIndex: 1400, width: searchRef.current?.offsetWidth }}
|
||||
>
|
||||
<Paper sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}>
|
||||
{loadingClients && results.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2.5 }}><CircularProgress size={20} /></Box>
|
||||
) : results.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">No clients match “{search.trim()}”.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{results.map((c) => (
|
||||
<MenuItem key={c.id} onClick={() => goToClients(c.name)} sx={{ py: 1, gap: 1.25 }}>
|
||||
<UserAvatar name={c.name} size={30} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{[c.businessType, c.city].filter(Boolean).join(' · ') || c.phone}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => goToClients(search.trim())} sx={{ py: 1.25, color: 'primary.main', fontWeight: 600 }}>
|
||||
<SearchIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
See all results for “{search.trim()}”
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Popper>
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
|
||||
<Box
|
||||
onClick={(e) => setAccount(e.currentTarget)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 0.5, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: 'grey.100' } }}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
|
||||
{activeUserName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||
{displayRole}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={account}
|
||||
open={Boolean(account)}
|
||||
onClose={() => setAccount(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, minWidth: 220 } }}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Avatar sx={{ width: 38, height: 38, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
|
||||
<Box sx={{ lineHeight: 1.2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{activeUserName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'capitalize' }}>{displayRole}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/settings'); }}>
|
||||
<ListItemIcon><SettingsOutlinedIcon fontSize="small" /></ListItemIcon>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); localStorage.removeItem('auth_token'); navigate('/login'); }} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<>
|
||||
<TopNav
|
||||
label="Main navigation"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
|
||||
}}
|
||||
heading={
|
||||
<TopNavHeading
|
||||
logo={<Logo compact height={28} />}
|
||||
heading="Doormile CRM"
|
||||
subheading={displayRole}
|
||||
headingHref="/dashboard"
|
||||
/>
|
||||
}
|
||||
endContent={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'ghost',
|
||||
size: 'lg',
|
||||
icon: <UserAvatar name={activeUserName} size={22} />,
|
||||
label: activeUserName
|
||||
}}
|
||||
items={[
|
||||
{ label: 'Settings', icon: Settings, onClick: () => navigate('/settings') },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: 'Logout',
|
||||
icon: LogOut,
|
||||
onClick: () => { localStorage.removeItem('auth_token'); navigate('/login'); }
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,204 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Toolbar,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import navItems from '@/menu/navItems';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
export const DRAWER_WIDTH = 264;
|
||||
export const MINI_WIDTH = 78;
|
||||
// ==============================|| DOORMILE - SIDE NAV ||============================== //
|
||||
// Thin wrapper around Astryx's SideNav template: sections + items driven by navItems.
|
||||
// Branding lives in the TopNav heading, so this stays icon+label only (Astryx guidance:
|
||||
// avoid a SideNavHeading when TopNav already carries app identity).
|
||||
|
||||
const 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: open ? 1 : 0.75,
|
||||
px: open ? 1.5 : 0,
|
||||
justifyContent: open ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: active ? RED : 'grey.700',
|
||||
'& .MuiListItemIcon-root': { color: active ? RED : 'grey.500' },
|
||||
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } },
|
||||
'&.Mui-selected': {
|
||||
bgcolor: alpha(RED, 0.08),
|
||||
color: RED,
|
||||
'& .MuiListItemIcon-root': { color: RED },
|
||||
borderLeft: open ? `4px solid ${RED}` : 'none',
|
||||
'&:hover': { bgcolor: alpha(RED, 0.12) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: open ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
{depth > 0 && !Icon ? <FiberManualRecordIcon sx={{ fontSize: 8 }} /> : Icon ? <Icon fontSize="small" /> : null}
|
||||
</ListItemIcon>
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: active ? 700 : 500 }}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
return open ? button : <Tooltip title={item.title} placement="right">{button}</Tooltip>;
|
||||
}
|
||||
|
||||
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
export default function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isActive = (url) => url && location.pathname.startsWith(url);
|
||||
const expanded = open || isMobile;
|
||||
|
||||
const initialOpen = navItems
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||
.map((i) => i.id);
|
||||
const [collapse, setCollapse] = useState(initialOpen);
|
||||
|
||||
const go = (url) => {
|
||||
navigate(url);
|
||||
if (isMobile) onMobileClose();
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box sx={{ bgcolor: '#fff', height: '100%', color: 'grey.800', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||
<Logo compact={!expanded} />
|
||||
</Toolbar>
|
||||
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
|
||||
{navItems.map((grp) => (
|
||||
<Box key={grp.group} sx={{ mt: 1 }}>
|
||||
{expanded && (
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2.5, color: '#A06060', fontSize: '0.6875rem', letterSpacing: '0.08em', fontWeight: 700 }}
|
||||
>
|
||||
{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 head = (
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
expanded
|
||||
? setCollapse((p) => (p.includes(item.id) ? p.filter((x) => x !== item.id) : [...p, item.id]))
|
||||
: go(item.children[0].url)
|
||||
}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: expanded ? 1 : 0.75,
|
||||
px: expanded ? 1.5 : 0,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: childActive ? RED : 'grey.700',
|
||||
bgcolor: childActive && !opened ? alpha(RED, 0.08) : 'transparent',
|
||||
'& .MuiListItemIcon-root': { color: childActive ? RED : 'grey.500' },
|
||||
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } }
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{expanded && (
|
||||
<>
|
||||
<ListItemText primary={item.title} primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 500 }} />
|
||||
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||
</>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
{expanded ? head : <Tooltip title={item.title} placement="right">{head}</Tooltip>}
|
||||
{expanded && (
|
||||
<Collapse in={opened} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 1.5 }}>
|
||||
{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>
|
||||
{expanded && (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Doormile CRM 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' } }}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
const isActive = (url) => !!url && location.pathname.startsWith(url);
|
||||
|
||||
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: (t) => t.transitions.create('width', { duration: t.transitions.duration.standard })
|
||||
<>
|
||||
<SideNav
|
||||
className="doormile-side-nav"
|
||||
collapsible={{ defaultIsCollapsed: true, buttonLabel: 'Collapse navigation' }}
|
||||
style={{
|
||||
// White + a right border/shadow matches the TopNav's chrome so the
|
||||
// top and side nav read as one unified surface instead of two
|
||||
// mismatched panels (TopNav is white with a bottom border already).
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
||||
paddingBlock: '12px',
|
||||
paddingInline: '8px',
|
||||
boxSizing: 'border-box',
|
||||
// Astryx's collapsed-rail width reads var(--spacing-12) (48px default),
|
||||
// which feels cramped for our icons. Overriding the token on this
|
||||
// element only widens the collapsed rail without touching the same
|
||||
// token's unrelated uses elsewhere (e.g. AppShell's mobile top bar).
|
||||
'--spacing-12': '72px'
|
||||
}}
|
||||
>
|
||||
{navItems.map((grp) => (
|
||||
<SideNavSection key={grp.group} title={grp.group}>
|
||||
{grp.items.map((item) => (
|
||||
<SideNavItem
|
||||
key={item.id}
|
||||
label={item.title}
|
||||
icon={item.icon}
|
||||
href={item.url}
|
||||
isSelected={isActive(item.url)}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
))}
|
||||
</SideNav>
|
||||
<style>{`
|
||||
/* Astryx packs items 2px apart by default, which reads as one
|
||||
merged block on hover instead of distinct rows. The items
|
||||
wrapper has no stable class of its own, but it's always the
|
||||
section's second/last child div, right after the header. */
|
||||
.doormile-side-nav .astryx-side-nav-section > div:last-child {
|
||||
gap: 6px !important;
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
|
||||
/* Collapsed nav items are the only ones Astryx renders with an
|
||||
aria-label on the item element itself (expanded rows show the
|
||||
label as visible text instead), so it doubles as a stable hook
|
||||
for "collapsed only" styling without touching the expanded rail. */
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-inline: auto;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
|
||||
outline: 2px solid rgba(192, 18, 39, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
|
||||
background-color: rgba(192, 18, 39, 0.12) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
|
||||
color: #C01227 !important;
|
||||
}
|
||||
|
||||
/* Expanded rows: rounded hover/selected states with a left accent
|
||||
bar on the active item, instead of the default flat highlight.
|
||||
The accent bar is always present (via ::before) but only faded
|
||||
in on selection, so clicking a link fades it in smoothly instead
|
||||
of the bar snapping into place flush against the row edges.
|
||||
Row sizing mirrors doormile_console's NavItem pattern (py: 1,
|
||||
i.e. 8px top/bottom padding on the button itself) instead of a
|
||||
fixed-height row: the highlight is painted directly on the item's
|
||||
own (now-padded) background, so it naturally gets breathing room
|
||||
above and below rather than filling a cramped fixed-height box
|
||||
edge to edge. */
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
|
||||
position: relative;
|
||||
margin-inline: 2px;
|
||||
height: auto;
|
||||
padding-block: 8px !important;
|
||||
transition: background-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background-color: #C01227;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
|
||||
background-color: rgba(192, 18, 39, 0.05) !important;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
|
||||
outline: 2px solid rgba(192, 18, 39, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
|
||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected']::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] .astryx-icon {
|
||||
color: #C01227 !important;
|
||||
}
|
||||
|
||||
/* Collapse/expand toggle (the "<" / ">" chevron button). It sits
|
||||
right on the sidebar's edge, so the default square ghost-button
|
||||
hover clips against that border and looks broken. Give it a
|
||||
clean circular hover detached from the edge instead. */
|
||||
.doormile-side-nav button[aria-label*="sidebar"],
|
||||
.doormile-side-nav button[aria-label*="navigation"] {
|
||||
border-radius: 50% !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
transform: translateX(-6px) !important;
|
||||
}
|
||||
.doormile-side-nav button[aria-label*="sidebar"]:hover,
|
||||
.doormile-side-nav button[aria-label*="navigation"]:hover {
|
||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box, Toolbar, useMediaQuery } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||
|
||||
import Header from './Header';
|
||||
import Sidebar, { DRAWER_WIDTH, MINI_WIDTH } from './Sidebar';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
// ==============================|| DOORMILE - APP SHELL ||============================== //
|
||||
// Astryx AppShell owns the responsive chrome: sticky top nav, collapsible side nav,
|
||||
// and the auto-generated mobile drawer. Pages keep their own internal padding.
|
||||
|
||||
export default function MainLayout() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
const [open, setOpen] = useState(true);
|
||||
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,
|
||||
width: { lg: `calc(100% - ${open ? DRAWER_WIDTH : MINI_WIDTH}px)` },
|
||||
minHeight: '100vh',
|
||||
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }} />
|
||||
<Box sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<AppShell
|
||||
variant="section"
|
||||
height="fill"
|
||||
contentPadding={0}
|
||||
topNav={<Header />}
|
||||
sideNav={<Sidebar />}
|
||||
mobileNav={{ breakpoint: 'lg' }}
|
||||
>
|
||||
<div className="main-content-area" style={{ minHeight: '100%', boxSizing: 'border-box' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<style>{`
|
||||
.main-content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.main-content-area {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
// Used by auth + maintenance pages — full-bleed, no shell.
|
||||
export default function MinimalLayout() {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
<div style={{ minHeight: '100vh', backgroundColor: '#f8fafc' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user