Finalize CRM module: bookings, pricing, survey with full UI integration and validation
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
@@ -26,7 +26,8 @@ import LogoutIcon from '@mui/icons-material/Logout';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
||||
import { fetchClients, fetchUsers } from '@/utils/apiClient';
|
||||
import { toClient } from '@/utils/mappers';
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
@@ -34,25 +35,50 @@ 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();
|
||||
|
||||
// Live client search for the top bar.
|
||||
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);
|
||||
fetchPoints(COLLECTIONS.clients)
|
||||
.then((points) => setClients(points.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.payload?.name || '—',
|
||||
city: p.payload?.city || '',
|
||||
businessType: p.payload?.businessType || '',
|
||||
phone: p.payload?.phone || ''
|
||||
}))))
|
||||
fetchClients()
|
||||
.then((points) => setClients(points.map(toClient)))
|
||||
.catch(() => {})
|
||||
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
||||
};
|
||||
@@ -84,24 +110,22 @@ export default function Header({ onToggle }) {
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{ bgcolor: RED, color: '#fff', zIndex: (t) => t.zIndex.drawer + 1, boxShadow: '0 1px 0 rgba(0,0,0,0.06)' }}
|
||||
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>
|
||||
|
||||
{/* Brand wordmark — left side */}
|
||||
<Box
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<Logo onDark height={22} />
|
||||
<Logo height={22} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Search — live client lookup */}
|
||||
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
|
||||
<Box
|
||||
@@ -111,23 +135,23 @@ export default function Header({ onToggle }) {
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: alpha('#fff', 0.16),
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
'&:hover': { bgcolor: alpha('#fff', 0.22) },
|
||||
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
|
||||
'&:hover': { bgcolor: 'grey.200' },
|
||||
'&:focus-within': { bgcolor: 'grey.200' }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<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: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
||||
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
|
||||
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: 'grey.500' } }}
|
||||
inputProps={{ 'aria-label': 'search' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -171,20 +195,19 @@ export default function Header({ onToggle }) {
|
||||
|
||||
<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: alpha('#fff', 0.14) } }}
|
||||
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: '#fff', color: RED, fontWeight: 700 }}>A</Avatar>
|
||||
<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: '#fff', fontWeight: 600 }}>
|
||||
Admin
|
||||
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
|
||||
{activeUserName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
|
||||
Operations Admin
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||
{displayRole}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Account dropdown */}
|
||||
<Menu
|
||||
anchorEl={account}
|
||||
open={Boolean(account)}
|
||||
@@ -195,10 +218,10 @@ export default function Header({ onToggle }) {
|
||||
>
|
||||
<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 }}>A</Avatar>
|
||||
<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 }}>Admin</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Operations Admin</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{activeUserName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'capitalize' }}>{displayRole}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -208,7 +231,7 @@ export default function Header({ onToggle }) {
|
||||
Settings
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}>
|
||||
<MenuItem onClick={() => { setAccount(null); localStorage.removeItem('auth_token'); navigate('/login'); }} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
Logout
|
||||
</MenuItem>
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Typography,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Toolbar
|
||||
Toolbar,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
@@ -37,13 +38,15 @@ function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||
px: open ? 1.5 : 0,
|
||||
justifyContent: open ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: 'rgba(255,255,255,0.78)',
|
||||
'& .MuiListItemIcon-root': { color: 'inherit' },
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' },
|
||||
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: 'rgba(255,255,255,0.18)',
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.22)' }
|
||||
bgcolor: alpha(RED, 0.08),
|
||||
color: RED,
|
||||
'& .MuiListItemIcon-root': { color: RED },
|
||||
borderLeft: open ? `4px solid ${RED}` : 'none',
|
||||
'&:hover': { bgcolor: alpha(RED, 0.12) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -80,9 +83,9 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box sx={{ bgcolor: RED, height: '100%', color: '#fff', display: 'flex', flexDirection: 'column' }}>
|
||||
<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 onDark compact={!expanded} />
|
||||
<Logo compact={!expanded} />
|
||||
</Toolbar>
|
||||
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
|
||||
{navItems.map((grp) => (
|
||||
@@ -90,7 +93,7 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
{expanded && (
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2.5, color: 'rgba(255,255,255,0.55)', fontSize: '0.6875rem', letterSpacing: '0.08em' }}
|
||||
sx={{ px: 2.5, color: '#A06060', fontSize: '0.6875rem', letterSpacing: '0.08em', fontWeight: 700 }}
|
||||
>
|
||||
{grp.group}
|
||||
</Typography>
|
||||
@@ -115,12 +118,13 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
px: expanded ? 1.5 : 0,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: childActive ? '#fff' : 'rgba(255,255,255,0.78)',
|
||||
bgcolor: childActive && !opened ? 'rgba(255,255,255,0.12)' : 'transparent',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' }
|
||||
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', color: 'inherit' }}>
|
||||
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{expanded && (
|
||||
@@ -155,8 +159,8 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
))}
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid rgba(255,255,255,0.12)' }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.55)' }}>
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Doormile CRM v1.0
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user