Finalize CRM transformation, mobile responsiveness, and Qdrant integration
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
@@ -6,72 +6,78 @@ import {
|
||||
IconButton,
|
||||
Box,
|
||||
InputBase,
|
||||
Badge,
|
||||
Avatar,
|
||||
Typography,
|
||||
Stack,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Button,
|
||||
Stack,
|
||||
Popper,
|
||||
Paper,
|
||||
ClickAwayListener,
|
||||
CircularProgress,
|
||||
alpha
|
||||
} 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 ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
const INITIAL_NOTIFICATIONS = [
|
||||
{ id: 1, icon: Inventory2OutlinedIcon, title: 'New order #ORD-10482 placed', time: '2 min ago', to: '/orders', read: false },
|
||||
{ id: 2, icon: TwoWheelerOutlinedIcon, title: 'Rider Imran went online', time: '18 min ago', to: '/riders', read: false },
|
||||
{ id: 3, icon: PaymentsOutlinedIcon, title: 'Invoice INV-2041 marked paid', time: '1 hr ago', to: '/invoice', read: false },
|
||||
{ id: 4, icon: AssignmentOutlinedIcon, title: '3 new onboarding requests', time: '3 hrs ago', to: '/requests', read: true }
|
||||
];
|
||||
|
||||
const MESSAGES = [
|
||||
{ id: 1, name: 'Priya Nair', text: 'Can we reroute the MG Road batch?', time: '5 min ago', initials: 'PN' },
|
||||
{ id: 2, name: 'Imran Khan', text: 'Reached the warehouse, loading now.', time: '22 min ago', initials: 'IK' },
|
||||
{ id: 3, name: 'Acme Logistics', text: 'Please confirm the revised pricing.', time: '2 hrs ago', initials: 'AL' }
|
||||
];
|
||||
|
||||
export default function Header({ onToggle }) {
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [notifAnchor, setNotifAnchor] = useState(null);
|
||||
const [msgAnchor, setMsgAnchor] = useState(null);
|
||||
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
// 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);
|
||||
|
||||
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();
|
||||
navigate(n.to);
|
||||
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 || ''
|
||||
}))))
|
||||
.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 q = search.trim();
|
||||
if (q) navigate(`/orders?q=${encodeURIComponent(q)}`);
|
||||
const term = search.trim();
|
||||
if (term) goToClients(term);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,55 +101,82 @@ export default function Header({ onToggle }) {
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Search — moved to the right */}
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={submitSearch}
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
bgcolor: alpha('#fff', 0.16),
|
||||
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) }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<InputBase
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search orders, riders, customers…"
|
||||
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
||||
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
|
||||
/>
|
||||
</Box>
|
||||
{/* Search — live client lookup */}
|
||||
<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: alpha('#fff', 0.16),
|
||||
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) }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<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' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Tooltip title="Messages">
|
||||
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
|
||||
<Badge badgeContent={MESSAGES.length} color="warning">
|
||||
<ChatBubbleOutlineIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Notifications">
|
||||
<IconButton color="inherit" onClick={openNotif}>
|
||||
<Badge badgeContent={unread} color="warning">
|
||||
<NotificationsNoneIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<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: alpha('#fff', 0.14) } }}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>AD</Avatar>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>A</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||
Aman Deshmukh
|
||||
Admin
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
|
||||
Operations Admin
|
||||
@@ -151,87 +184,6 @@ export default function Header({ onToggle }) {
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { closeNotif(); navigate('/requests'); }} sx={{ justifyContent: 'center', color: 'primary.main', fontWeight: 600 }}>
|
||||
View all activity
|
||||
</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={() => setMsgAnchor(null)} 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}
|
||||
@@ -239,12 +191,18 @@ export default function Header({ onToggle }) {
|
||||
onClose={() => setAccount(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
|
||||
PaperProps={{ sx: { mt: 1, minWidth: 220 } }}
|
||||
>
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/profile'); }}>
|
||||
<ListItemIcon><PersonOutlineIcon fontSize="small" /></ListItemIcon>
|
||||
View Profile
|
||||
</MenuItem>
|
||||
<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>
|
||||
<Box sx={{ lineHeight: 1.2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Admin</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Operations Admin</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/settings'); }}>
|
||||
<ListItemIcon><SettingsOutlinedIcon fontSize="small" /></ListItemIcon>
|
||||
Settings
|
||||
|
||||
Reference in New Issue
Block a user