243 lines
9.3 KiB
JavaScript
243 lines
9.3 KiB
JavaScript
import { useState, useRef, 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 Logo from '@/components/Logo';
|
|
import UserAvatar from '@/components/UserAvatar';
|
|
import { fetchClients, fetchUsers } from '@/utils/apiClient';
|
|
import { toClient } from '@/utils/mappers';
|
|
|
|
const RED = '#C01227';
|
|
|
|
export default function Header({ onToggle }) {
|
|
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) {}
|
|
|
|
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
|
|
|
|
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));
|
|
}
|
|
}
|
|
}).catch(console.error);
|
|
}, []);
|
|
|
|
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>
|
|
);
|
|
}
|