Finalize CRM module: bookings, pricing, survey with full UI integration and validation

This commit is contained in:
2026-06-22 17:47:36 +05:30
parent 59fc91f034
commit 72a1eb0701
23 changed files with 2620 additions and 326 deletions

View File

@@ -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>

View File

@@ -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>