Finalize CRM module: bookings, pricing, survey with full UI integration and validation
This commit is contained in:
BIN
Enquiry.xlsx
Normal file
BIN
Enquiry.xlsx
Normal file
Binary file not shown.
1
scratch.txt
Normal file
1
scratch.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
none
|
||||||
@@ -31,6 +31,12 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
|
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
|
||||||
|
|
||||||
|
<Route path="/survey" element={load(() => import('@/pages/survey/Survey.jsx'))} />
|
||||||
|
|
||||||
|
<Route path="/pricing" element={load(() => import('@/pages/pricing/Pricing.jsx'))} />
|
||||||
|
|
||||||
|
<Route path="/bookings" element={load(() => import('@/pages/bookings/Bookings'))} />
|
||||||
|
|
||||||
<Route path="/team-users" element={load(() => import('@/pages/team/TeamUsers'))} />
|
<Route path="/team-users" element={load(() => import('@/pages/team/TeamUsers'))} />
|
||||||
|
|
||||||
<Route path="/settings" element={load(() => import('@/pages/Settings'))} />
|
<Route path="/settings" element={load(() => import('@/pages/Settings'))} />
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const INACTIVITY_LIMIT_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
export default function AuthGuard({ children }) {
|
export default function AuthGuard({ children }) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const navigate = useNavigate();
|
||||||
|
const loggedIn = localStorage.getItem('logged_in');
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
if (!token) {
|
useEffect(() => {
|
||||||
|
if (!loggedIn) return;
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('logged_in');
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('user_data');
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTimer = () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(logout, INACTIVITY_LIMIT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = ['mousemove', 'keydown', 'scroll', 'click'];
|
||||||
|
|
||||||
|
events.forEach(event => window.addEventListener(event, resetTimer));
|
||||||
|
resetTimer(); // Start the timer initially
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
events.forEach(event => window.removeEventListener(event, resetTimer));
|
||||||
|
};
|
||||||
|
}, [loggedIn, navigate]);
|
||||||
|
|
||||||
|
if (!loggedIn) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export default function Logo({ onDark = false, compact = false, height = 26, sx
|
|||||||
height,
|
height,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
// The asset is white; on light surfaces recolour it to near-black so it stays visible.
|
// The asset is white; on light surfaces recolour it to Doormile Red (#C01227)
|
||||||
filter: onDark ? 'none' : 'brightness(0) saturate(100%)'
|
filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
@@ -26,7 +26,8 @@ import LogoutIcon from '@mui/icons-material/Logout';
|
|||||||
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
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';
|
const RED = '#C01227';
|
||||||
|
|
||||||
@@ -35,24 +36,49 @@ export default function Header({ onToggle }) {
|
|||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// Live client search for the top bar.
|
// 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 searchRef = useRef(null);
|
||||||
const [clients, setClients] = useState([]);
|
const [clients, setClients] = useState([]);
|
||||||
const [loadedClients, setLoadedClients] = useState(false);
|
const [loadedClients, setLoadedClients] = useState(false);
|
||||||
const [loadingClients, setLoadingClients] = useState(false);
|
const [loadingClients, setLoadingClients] = useState(false);
|
||||||
const [openResults, setOpenResults] = 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 = () => {
|
const ensureClients = () => {
|
||||||
if (loadedClients || loadingClients) return;
|
if (loadedClients || loadingClients) return;
|
||||||
setLoadingClients(true);
|
setLoadingClients(true);
|
||||||
fetchPoints(COLLECTIONS.clients)
|
fetchClients()
|
||||||
.then((points) => setClients(points.map((p) => ({
|
.then((points) => setClients(points.map(toClient)))
|
||||||
id: p.id,
|
|
||||||
name: p.payload?.name || '—',
|
|
||||||
city: p.payload?.city || '',
|
|
||||||
businessType: p.payload?.businessType || '',
|
|
||||||
phone: p.payload?.phone || ''
|
|
||||||
}))))
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
||||||
};
|
};
|
||||||
@@ -84,24 +110,22 @@ export default function Header({ onToggle }) {
|
|||||||
<AppBar
|
<AppBar
|
||||||
position="fixed"
|
position="fixed"
|
||||||
elevation={0}
|
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 }}>
|
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
|
||||||
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Brand wordmark — left side */}
|
|
||||||
<Box
|
<Box
|
||||||
onClick={() => navigate('/dashboard')}
|
onClick={() => navigate('/dashboard')}
|
||||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<Logo onDark height={22} />
|
<Logo height={22} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
{/* Search — live client lookup */}
|
|
||||||
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
|
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
|
||||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
|
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
|
||||||
<Box
|
<Box
|
||||||
@@ -111,23 +135,23 @@ export default function Header({ onToggle }) {
|
|||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
bgcolor: alpha('#fff', 0.16),
|
bgcolor: 'grey.100',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
px: 1.5,
|
px: 1.5,
|
||||||
py: 0.5,
|
py: 0.5,
|
||||||
width: { sm: 240, md: 320 },
|
width: { sm: 240, md: 320 },
|
||||||
'&:hover': { bgcolor: alpha('#fff', 0.22) },
|
'&:hover': { bgcolor: 'grey.200' },
|
||||||
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
|
'&:focus-within': { bgcolor: 'grey.200' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'grey.500' }} />
|
||||||
<InputBase
|
<InputBase
|
||||||
value={search}
|
value={search}
|
||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
|
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
|
||||||
placeholder="Search clients…"
|
placeholder="Search clients…"
|
||||||
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: 'grey.500' } }}
|
||||||
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
|
inputProps={{ 'aria-label': 'search' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -171,20 +195,19 @@ export default function Header({ onToggle }) {
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
onClick={(e) => setAccount(e.currentTarget)}
|
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 }}>
|
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
|
||||||
Admin
|
{activeUserName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||||
Operations Admin
|
{displayRole}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Account dropdown */}
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={account}
|
anchorEl={account}
|
||||||
open={Boolean(account)}
|
open={Boolean(account)}
|
||||||
@@ -195,10 +218,10 @@ export default function Header({ onToggle }) {
|
|||||||
>
|
>
|
||||||
<Box sx={{ px: 2, py: 1.5 }}>
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
<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 }}>
|
<Box sx={{ lineHeight: 1.2 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Admin</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{activeUserName}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Operations Admin</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'capitalize' }}>{displayRole}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -208,7 +231,7 @@ export default function Header({ onToggle }) {
|
|||||||
Settings
|
Settings
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<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>
|
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||||
Logout
|
Logout
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Collapse,
|
Collapse,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Toolbar
|
Toolbar,
|
||||||
|
alpha
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
@@ -37,13 +38,15 @@ function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
|||||||
px: open ? 1.5 : 0,
|
px: open ? 1.5 : 0,
|
||||||
justifyContent: open ? 'flex-start' : 'center',
|
justifyContent: open ? 'flex-start' : 'center',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
color: 'rgba(255,255,255,0.78)',
|
color: active ? RED : 'grey.700',
|
||||||
'& .MuiListItemIcon-root': { color: 'inherit' },
|
'& .MuiListItemIcon-root': { color: active ? RED : 'grey.500' },
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' },
|
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } },
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
bgcolor: 'rgba(255,255,255,0.18)',
|
bgcolor: alpha(RED, 0.08),
|
||||||
color: '#fff',
|
color: RED,
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.22)' }
|
'& .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 = (
|
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 }}>
|
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||||
<Logo onDark compact={!expanded} />
|
<Logo compact={!expanded} />
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
|
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
|
||||||
{navItems.map((grp) => (
|
{navItems.map((grp) => (
|
||||||
@@ -90,7 +93,7 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
|||||||
{expanded && (
|
{expanded && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="overline"
|
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}
|
{grp.group}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -115,12 +118,13 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
|||||||
px: expanded ? 1.5 : 0,
|
px: expanded ? 1.5 : 0,
|
||||||
justifyContent: expanded ? 'flex-start' : 'center',
|
justifyContent: expanded ? 'flex-start' : 'center',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
color: childActive ? '#fff' : 'rgba(255,255,255,0.78)',
|
color: childActive ? RED : 'grey.700',
|
||||||
bgcolor: childActive && !opened ? 'rgba(255,255,255,0.12)' : 'transparent',
|
bgcolor: childActive && !opened ? alpha(RED, 0.08) : 'transparent',
|
||||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' }
|
'& .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" />
|
<Icon fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -155,8 +159,8 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Box sx={{ p: 2, borderTop: '1px solid rgba(255,255,255,0.12)' }}>
|
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.55)' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
Doormile CRM v1.0
|
Doormile CRM v1.0
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
|
|||||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||||
|
import AttachMoneyOutlinedIcon from '@mui/icons-material/AttachMoneyOutlined';
|
||||||
|
|
||||||
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
|
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ const navItems = [
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
|
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
|
||||||
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon },
|
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon },
|
||||||
|
{ id: 'survey', title: 'Providers', url: '/survey', icon: DashboardOutlinedIcon },
|
||||||
|
{ id: 'pricing', title: 'Pricing Matrix', url: '/pricing', icon: AttachMoneyOutlinedIcon },
|
||||||
|
{ id: 'bookings', title: 'Bookings', url: '/bookings', icon: DashboardOutlinedIcon },
|
||||||
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: GroupsOutlinedIcon }
|
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: GroupsOutlinedIcon }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,42 +19,14 @@ import StatusChip from '@/components/StatusChip';
|
|||||||
import DonutChart from '@/components/charts/DonutChart';
|
import DonutChart from '@/components/charts/DonutChart';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
import { fetchClients, fetchUsers } from '@/utils/apiClient';
|
||||||
import bgImage from '@/assets/premium_logistics_bg.png';
|
import { toClient, toUser } from '@/utils/mappers';
|
||||||
|
import { titleCase } from '@/utils/format';
|
||||||
const titleCase = (s) =>
|
|
||||||
String(s || '').replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
|
|
||||||
|
|
||||||
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
|
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
|
||||||
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
|
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
|
||||||
const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C'];
|
const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C'];
|
||||||
|
|
||||||
const generateLogicalId = (id) => {
|
|
||||||
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
||||||
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
|
||||||
};
|
|
||||||
|
|
||||||
function toClient(point) {
|
|
||||||
const p = point.payload || {};
|
|
||||||
return {
|
|
||||||
id: point.id,
|
|
||||||
logicalId: generateLogicalId(point.id),
|
|
||||||
name: p.name || '—',
|
|
||||||
businessType: p.businessType || '',
|
|
||||||
city: p.city || '',
|
|
||||||
businessState: p.businessState || '',
|
|
||||||
status: p.status || 'unknown',
|
|
||||||
parcelVolume: Number(p.parcelVolume) || 0,
|
|
||||||
activeContracts: Number(p.activeContracts) || 0,
|
|
||||||
lastUpdated: p.lastUpdated || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function toUser(point) {
|
|
||||||
const p = point.payload || {};
|
|
||||||
return { id: point.id, name: p.name || '—', email: p.email || '', role: p.role || 'unknown' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card with a tinted icon header band.
|
|
||||||
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
||||||
return (
|
return (
|
||||||
<Card sx={{
|
<Card sx={{
|
||||||
@@ -92,10 +64,10 @@ export default function Dashboard() {
|
|||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)])
|
Promise.all([fetchClients(), fetchUsers()])
|
||||||
.then(([cs, us]) => {
|
.then(([cs, us]) => {
|
||||||
setClients(cs.map(toClient));
|
setClients((cs || []).map(toClient));
|
||||||
setTeam(us.map(toUser));
|
setTeam((us || []).map(toUser));
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const TIMEZONES = ['Asia/Kolkata (IST)', 'Asia/Dubai (GST)', 'UTC', 'America/New
|
|||||||
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
|
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
|
||||||
|
|
||||||
const INITIAL_GENERAL = {
|
const INITIAL_GENERAL = {
|
||||||
orgName: 'Doormile Logistics Pvt. Ltd.',
|
orgName: 'Doormile Technologies',
|
||||||
supportEmail: 'support@doormile.in',
|
supportEmail: 'support@doormile.in',
|
||||||
contact: '+91 63749 46729',
|
contact: '+91 63749 46729',
|
||||||
timezone: TIMEZONES[0],
|
timezone: TIMEZONES[0],
|
||||||
|
|||||||
@@ -20,11 +20,31 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
|||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import bgImage from '../../assets/mid-mile-approach.jpg';
|
import bgImage from '../../assets/mid-mile-approach.jpg';
|
||||||
|
|
||||||
|
import { loginAdmin } from '@/utils/apiClient';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [auth, setAuth] = useState('');
|
const [auth, setAuth] = useState('');
|
||||||
const [pwd, setPwd] = useState('');
|
const [pwd, setPwd] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
const data = await loginAdmin(auth, pwd);
|
||||||
|
localStorage.setItem('logged_in', 'true');
|
||||||
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Invalid email or password');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') handleLogin();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -70,12 +90,13 @@ export default function Login() {
|
|||||||
|
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Auth Name</Typography>
|
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Email</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="Enter your auth name"
|
placeholder="Enter your email address"
|
||||||
value={auth}
|
value={auth}
|
||||||
onChange={(e) => setAuth(e.target.value)}
|
onChange={(e) => setAuth(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'rgba(255,255,255,0.6)',
|
bgcolor: 'rgba(255,255,255,0.6)',
|
||||||
@@ -95,6 +116,7 @@ export default function Login() {
|
|||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={pwd}
|
value={pwd}
|
||||||
onChange={(e) => setPwd(e.target.value)}
|
onChange={(e) => setPwd(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'rgba(255,255,255,0.6)',
|
bgcolor: 'rgba(255,255,255,0.6)',
|
||||||
@@ -119,11 +141,14 @@ export default function Login() {
|
|||||||
<FormControlLabel control={<Checkbox defaultChecked size="small" sx={{ color: '#cbd5e1', '&.Mui-checked': { color: 'primary.main' } }} />} label={<Typography variant="body2" sx={{ color: '#475569', fontWeight: 500 }}>Remember me</Typography>} />
|
<FormControlLabel control={<Checkbox defaultChecked size="small" sx={{ color: '#cbd5e1', '&.Mui-checked': { color: 'primary.main' } }} />} label={<Typography variant="body2" sx={{ color: '#475569', fontWeight: 500 }}>Remember me</Typography>} />
|
||||||
<Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link>
|
<Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{error && <Typography variant="body2" color="error" textAlign="center" sx={{ fontWeight: 600 }}>{error}</Typography>}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
size="large"
|
size="large"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => { localStorage.setItem('auth_token', 'demo-session'); navigate('/dashboard'); }}
|
onClick={handleLogin}
|
||||||
sx={{
|
sx={{
|
||||||
mt: 2,
|
mt: 2,
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
@@ -134,9 +159,8 @@ export default function Login() {
|
|||||||
boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)',
|
boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: '0 12px 20px rgba(192, 18, 39, 0.35)',
|
boxShadow: '0 12px 20px rgba(192, 18, 39, 0.35)',
|
||||||
transform: 'translateY(-1px)'
|
|
||||||
},
|
},
|
||||||
transition: 'all 0.2s ease'
|
transition: 'box-shadow 0.2s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
@@ -146,7 +170,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}>
|
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}>
|
||||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}>
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}>
|
||||||
© {new Date().getFullYear()} Doormile Logistics Pvt. Ltd. All rights reserved.
|
© {new Date().getFullYear()} Doormile Technologies. All rights reserved.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
545
src/pages/bookings/Bookings.jsx
Normal file
545
src/pages/bookings/Bookings.jsx
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
|
Typography, TextField, Button, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
Grid, IconButton, CircularProgress, Chip, MenuItem, InputAdornment, alpha, Autocomplete, Checkbox, FormControlLabel, Tabs, Tab, Snackbar, Alert
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { fetchClients } from '@/utils/apiClient';
|
||||||
|
import ClientFormDialog from '../tenants/ClientFormDialog';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||||
|
|
||||||
|
const CITIES = [
|
||||||
|
'Chennai', 'Coimbatore', 'Madurai', 'Tiruchirappalli', 'Salem', 'Tuticorin', 'Tirupur', 'Erode', 'Vellore', 'Tirunelveli', 'Thanjavur', 'Dindigul', 'Hosur', 'Nagercoil', 'Karur', 'Namakkal', 'Kanchipuram', 'Cuddalore', 'Thoothukudi',
|
||||||
|
'Bengaluru', 'Mysuru', 'Mangaluru', 'Hubli', 'Belagavi', 'Kalaburagi', 'Davangere', 'Ballari',
|
||||||
|
'Kochi', 'Thiruvananthapuram', 'Kozhikode', 'Kannur', 'Thrissur', 'Kollam',
|
||||||
|
'Hyderabad', 'Warangal', 'Visakhapatnam', 'Vijayawada', 'Tirupati', 'Guntur', 'Rajahmundry', 'Nellore',
|
||||||
|
'Mumbai', 'Pune', 'Nagpur', 'Nashik', 'Aurangabad', 'Kolhapur', 'Solapur',
|
||||||
|
'New Delhi', 'Gurugram', 'Noida', 'Faridabad', 'Chandigarh', 'Amritsar', 'Ludhiana', 'Jalandhar',
|
||||||
|
'Ahmedabad', 'Surat', 'Vadodara', 'Rajkot', 'Bhavnagar', 'Jamnagar',
|
||||||
|
'Kolkata', 'Howrah', 'Durgapur', 'Asansol', 'Siliguri',
|
||||||
|
'Lucknow', 'Kanpur', 'Agra', 'Varanasi', 'Allahabad', 'Meerut',
|
||||||
|
'Bhopal', 'Indore', 'Gwalior', 'Jabalpur',
|
||||||
|
'Jaipur', 'Jodhpur', 'Udaipur', 'Kota', 'Bikaner',
|
||||||
|
'Patna', 'Gaya', 'Bhagalpur',
|
||||||
|
'Bhubaneswar', 'Cuttack', 'Rourkela',
|
||||||
|
'Guwahati', 'Raipur', 'Ranchi', 'Dehradun'
|
||||||
|
];
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function apiFetchBookings() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/bookings`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch bookings');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetchSurveys() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||||
|
const json = await res.json();
|
||||||
|
return Array.isArray(json) ? json : (json.data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCreateBooking(payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/crmbooking`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to create booking');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
|
||||||
|
const [clientDialogOpen, setClientDialogOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
customer_name: '',
|
||||||
|
customer_phone: '',
|
||||||
|
pickupaddress: '',
|
||||||
|
pickuppincode: '',
|
||||||
|
deliveryaddress: '',
|
||||||
|
deliverypincode: '',
|
||||||
|
deliverycity: '',
|
||||||
|
providercompany: '',
|
||||||
|
providerlocation: '',
|
||||||
|
notes: '',
|
||||||
|
service_option: 'Normal',
|
||||||
|
finalprice: '',
|
||||||
|
insuranceamount: '',
|
||||||
|
needsinsurance: false,
|
||||||
|
declaredvalue: '',
|
||||||
|
parcels: [{ itemcategory: '', weight: '', length: '', width: '', height: '' }]
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [quoteInfo, setQuoteInfo] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormData({
|
||||||
|
customer_name: '',
|
||||||
|
customer_phone: '',
|
||||||
|
pickupaddress: '',
|
||||||
|
pickuppincode: '',
|
||||||
|
deliveryaddress: '',
|
||||||
|
deliverypincode: '',
|
||||||
|
deliverycity: '',
|
||||||
|
providercompany: '',
|
||||||
|
providerlocation: '',
|
||||||
|
notes: '',
|
||||||
|
service_option: 'Normal',
|
||||||
|
finalprice: '',
|
||||||
|
insuranceamount: '',
|
||||||
|
needsinsurance: false,
|
||||||
|
declaredvalue: '',
|
||||||
|
parcels: [{ itemcategory: '', weight: '', length: '', width: '', height: '' }]
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
setQuoteInfo(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleChange = (field) => (e) => setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const handleParcelChange = (index, field) => (e) => {
|
||||||
|
const newParcels = [...formData.parcels];
|
||||||
|
newParcels[index] = { ...newParcels[index], [field]: e.target.value };
|
||||||
|
setFormData(prev => ({ ...prev, parcels: newParcels }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addParcel = () => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
parcels: [...prev.parcels, { itemcategory: '', weight: '', length: '', width: '', height: '' }]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeParcel = (index) => {
|
||||||
|
if (formData.parcels.length <= 1) return;
|
||||||
|
const newParcels = [...formData.parcels];
|
||||||
|
newParcels.splice(index, 1);
|
||||||
|
setFormData(prev => ({ ...prev, parcels: newParcels }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (!formData.pickupaddress || !formData.pickuppincode) {
|
||||||
|
throw new Error('Pickup Address and Pincode are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
finalprice: parseFloat(formData.finalprice) || 0,
|
||||||
|
insuranceamount: parseFloat(formData.insuranceamount) || 0,
|
||||||
|
parcels: formData.parcels.map(p => ({
|
||||||
|
itemcategory: p.itemcategory || 'General',
|
||||||
|
weight: parseFloat(p.weight) || 1.0,
|
||||||
|
length: parseFloat(p.length) || 1.0,
|
||||||
|
width: parseFloat(p.width) || 1.0,
|
||||||
|
height: parseFloat(p.height) || 1.0,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
await apiCreateBooking(payload);
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message || 'Failed to create booking');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckPrice = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/pricing/quote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
parcels: formData.parcels.map(p => ({
|
||||||
|
weight: parseFloat(p.weight) || 1.0,
|
||||||
|
length: parseFloat(p.length) || 1.0,
|
||||||
|
width: parseFloat(p.width) || 1.0,
|
||||||
|
height: parseFloat(p.height) || 1.0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setQuoteInfo(data);
|
||||||
|
if (!formData.finalprice) {
|
||||||
|
setFormData(prev => ({ ...prev, finalprice: data.basequote?.toFixed(2) || '' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueProviders = Array.from(new Set(surveys.map(s => s.company).filter(Boolean)));
|
||||||
|
const providerLocations = surveys.filter(s => s.company === formData.providercompany).map(s => s.area || s.address).filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Snackbar
|
||||||
|
open={!!error}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
sx={{ zIndex: 9999 }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>Create New Booking</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Customer Details</Typography>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<Autocomplete
|
||||||
|
options={clients}
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
getOptionLabel={(option) => {
|
||||||
|
if (typeof option === 'string') return option;
|
||||||
|
return `${option.first_name || ''} ${option.last_name || ''} - ${option.phone || ''}`;
|
||||||
|
}}
|
||||||
|
onChange={(e, newValue) => {
|
||||||
|
if (newValue && typeof newValue === 'object') {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
customer_name: `${newValue.first_name || ''} ${newValue.last_name || ''}`.trim(),
|
||||||
|
customer_phone: newValue.phone || ''
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, customer_name: '', customer_phone: '' }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Select Existing Client" size="small" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setClientDialogOpen(true)} sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
New Client
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Assigned Provider (From Survey)</Typography>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
|
options={uniqueProviders}
|
||||||
|
value={formData.providercompany}
|
||||||
|
onChange={(e, val) => setFormData(prev => ({ ...prev, providercompany: val || '', providerlocation: '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Provider Name" size="small" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
|
options={providerLocations}
|
||||||
|
value={formData.providerlocation}
|
||||||
|
onChange={(e, val) => setFormData(prev => ({ ...prev, providerlocation: val || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Provider Location / Sub Hub" size="small" />}
|
||||||
|
disabled={!formData.providercompany}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Pickup Location</Typography>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={8}>
|
||||||
|
<TextField fullWidth label="Pickup Address *" value={formData.pickupaddress} onChange={handleChange('pickupaddress')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField fullWidth label="Pickup Pincode *" value={formData.pickuppincode} onChange={handleChange('pickuppincode')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Delivery Destination</Typography>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={12}>
|
||||||
|
<TextField fullWidth label="Delivery Address" value={formData.deliveryaddress} onChange={handleChange('deliveryaddress')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={8}>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={CITIES}
|
||||||
|
value={formData.deliverycity}
|
||||||
|
onChange={(e, val) => setFormData(prev => ({ ...prev, deliverycity: val || '' }))}
|
||||||
|
onInputChange={(e, val) => setFormData(prev => ({ ...prev, deliverycity: val || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Search City" size="small" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField fullWidth label="Delivery Pincode" value={formData.deliverypincode} onChange={handleChange('deliverypincode')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'primary.main' }}>Parcel Details</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addParcel}>Add Parcel</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{formData.parcels.map((parcel, idx) => (
|
||||||
|
<Grid container spacing={1.5} key={idx} sx={{ mb: 2, alignItems: 'center' }}>
|
||||||
|
<Grid item xs={12} sm={3}>
|
||||||
|
<TextField fullWidth label="Category (e.g. Box)" value={parcel.itemcategory} onChange={handleParcelChange(idx, 'itemcategory')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={2}>
|
||||||
|
<TextField fullWidth label="Weight (kg)" type="number" value={parcel.weight} onChange={handleParcelChange(idx, 'weight')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={2}>
|
||||||
|
<TextField fullWidth label="Length (cm)" type="number" value={parcel.length} onChange={handleParcelChange(idx, 'length')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={2}>
|
||||||
|
<TextField fullWidth label="Width (cm)" type="number" value={parcel.width} onChange={handleParcelChange(idx, 'width')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4} sm={2}>
|
||||||
|
<TextField fullWidth label="Height (cm)" type="number" value={parcel.height} onChange={handleParcelChange(idx, 'height')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={2} sm={1}>
|
||||||
|
<IconButton color="error" onClick={() => removeParcel(idx)} disabled={formData.parcels.length === 1}>
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2, mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'primary.main' }}>Price & Insurance</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={handleCheckPrice}>Check Price Estimate</Button>
|
||||||
|
</Box>
|
||||||
|
{quoteInfo && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, px: 1, borderLeft: '4px solid #3b82f6', bgcolor: '#eff6ff', py: 1 }}>
|
||||||
|
Estimated Base Price: <strong>₹{quoteInfo.basequote?.toFixed(2)}</strong> (Chargeable Weight: {quoteInfo.chargeableweight} kg)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={3}>
|
||||||
|
<TextField fullWidth label="Final Price (₹)" type="number" value={formData.finalprice} onChange={handleChange('finalprice')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={9}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={formData.needsinsurance} onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
needsinsurance: checked,
|
||||||
|
insuranceamount: checked && prev.declaredvalue ? (parseFloat(prev.declaredvalue) * 0.01).toFixed(2) : prev.insuranceamount
|
||||||
|
}));
|
||||||
|
}} />}
|
||||||
|
label="Needs Insurance? (1%)"
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Declared Value (₹)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
value={formData.declaredvalue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
declaredvalue: val,
|
||||||
|
insuranceamount: prev.needsinsurance && val ? (parseFloat(val) * 0.01).toFixed(2) : prev.insuranceamount
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Insurance Amount (₹)"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
value={formData.insuranceamount}
|
||||||
|
onChange={handleChange('insuranceamount')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Additional Information</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField select fullWidth label="Select Speed" value={formData.service_option} onChange={handleChange('service_option')} size="small">
|
||||||
|
<MenuItem value="Normal">Normal</MenuItem>
|
||||||
|
<MenuItem value="Fast">Fast Express</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Notes / Remarks" value={formData.notes} onChange={handleChange('notes')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2 }}>
|
||||||
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleSave} disabled={saving || !formData.customer_phone} sx={{ bgcolor: 'primary.main', '&:hover': { bgcolor: 'primary.dark' } }}>
|
||||||
|
{saving ? 'Creating...' : 'Create Booking'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
{/* Embedded Client Registration Form */}
|
||||||
|
{clientDialogOpen && (
|
||||||
|
<ClientFormDialog
|
||||||
|
open={clientDialogOpen}
|
||||||
|
mode="create"
|
||||||
|
onClose={() => setClientDialogOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setClientDialogOpen(false);
|
||||||
|
onSave(); // Refetch clients so they appear in dropdown
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Bookings() {
|
||||||
|
const [bookings, setBookings] = useState([]);
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [surveys, setSurveys] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sourceFilter, setSourceFilter] = useState('All');
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [bookingsData, clientsData, surveysData] = await Promise.all([
|
||||||
|
apiFetchBookings(),
|
||||||
|
fetchClients().catch(() => []),
|
||||||
|
apiFetchSurveys().catch(() => [])
|
||||||
|
]);
|
||||||
|
setBookings(Array.isArray(bookingsData) ? bookingsData : []);
|
||||||
|
setClients(Array.isArray(clientsData) ? clientsData : []);
|
||||||
|
setSurveys(Array.isArray(surveysData) ? surveysData : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayBookings = bookings.filter(b => {
|
||||||
|
const matchesSearch = (b.bookingno || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(b.pickupaddress || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(b.deliverycity || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(b.providercompany || '').toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesSource = sourceFilter === 'All' || b.bookingsource === sourceFilter;
|
||||||
|
return matchesSearch && matchesSource;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3, bgcolor: '#f8fafc', minHeight: '80vh' }}>
|
||||||
|
<PageHeader title="Bookings Management" breadcrumbs={[{ label: 'Bookings' }]} />
|
||||||
|
|
||||||
|
<Card sx={{ mt: 3, p: 3, borderRadius: 4, boxShadow: '0 12px 24px -4px rgba(0,0,0,0.05)' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search bookings..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
|
||||||
|
sx: { borderRadius: 3, bgcolor: alpha('#e2e8f0', 0.4) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setFormOpen(true)} sx={{ borderRadius: 2 }}>
|
||||||
|
New Booking
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
|
<Tabs value={sourceFilter} onChange={(e, val) => setSourceFilter(val)} aria-label="booking source filter tabs">
|
||||||
|
<Tab label="All Bookings" value="All" />
|
||||||
|
<Tab label="App Bookings" value="Customer_App" />
|
||||||
|
<Tab label="CRM Bookings" value="CRM_Console" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: alpha('#f1f5f9', 0.6) }}>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Booking No</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Delivery City</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Provider</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Source</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Price</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{displayBookings.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} align="center" sx={{ py: 4 }}>No bookings found.</TableCell></TableRow>
|
||||||
|
) : (
|
||||||
|
displayBookings.map(b => (
|
||||||
|
<TableRow key={b.bookingid} hover>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{b.bookingno}</TableCell>
|
||||||
|
<TableCell>{b.deliverycity || b.deliverypincode || 'N/A'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.providercompany ? (
|
||||||
|
<Chip label={b.providercompany} size="small" sx={{ mr: 1 }} />
|
||||||
|
) : 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={b.bookingsource === 'CRM_Console' ? 'CRM' : 'App'} size="small" variant="outlined" color={b.bookingsource === 'CRM_Console' ? 'secondary' : 'primary'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.serviceoptions && b.serviceoptions.length > 0 ? `₹${b.serviceoptions[0].estimatedprice}` : 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={b.status} size="small" color={b.status === 'Pending_Pickup' ? 'warning' : 'primary'} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<BookingFormDialog
|
||||||
|
open={formOpen}
|
||||||
|
onClose={() => setFormOpen(false)}
|
||||||
|
onSave={() => { loadData(); }}
|
||||||
|
clients={clients}
|
||||||
|
surveys={surveys}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
633
src/pages/pricing/Pricing.jsx
Normal file
633
src/pages/pricing/Pricing.jsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
|
Typography, TextField, InputAdornment, CircularProgress, MenuItem,
|
||||||
|
Select, FormControl, OutlinedInput, alpha, Avatar, Chip, Stack, Collapse, Button,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Grid, Tooltip, Alert, Snackbar
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||||
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { fetchUsers } from '@/utils/apiClient';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function apiFetchPricing() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/carrier-pricing?limit=1000`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch pricing');
|
||||||
|
const json = await res.json();
|
||||||
|
return Array.isArray(json) ? { data: json } : json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiSavePricing(data) {
|
||||||
|
const isUpdate = !!data.id;
|
||||||
|
const url = isUpdate ? `${API_BASE}/admin/carrier-pricing/${data.id}` : `${API_BASE}/admin/carrier-pricing`;
|
||||||
|
const method = isUpdate ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
company: data.company,
|
||||||
|
weight_slab: data.weight_slab,
|
||||||
|
zone: data.zone || '',
|
||||||
|
service_type: data.service_type || '',
|
||||||
|
rate: String(data.rate)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to save pricing');
|
||||||
|
}
|
||||||
|
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
||||||
|
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
||||||
|
|
||||||
|
// isCell=true → editing a specific cell (company/slab/zone locked, only rate editable)
|
||||||
|
// isCell=false → "Add Rate" button (all fields editable)
|
||||||
|
function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false, pricingList = [], providerColumns = [], providerUsesServiceType = false }) {
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormData(initialData || { company: '', weight_slab: '', zone: '', rate: '' });
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, initialData]);
|
||||||
|
|
||||||
|
const set = (field) => (e) => setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
// Column key: zone if available, otherwise service_type (e.g. DTDC)
|
||||||
|
const colKey = (p) => ((p.zone || '').trim() || (p.service_type || '').trim());
|
||||||
|
|
||||||
|
const findExistingRecord = (data) => {
|
||||||
|
const company = (data.company || '').trim();
|
||||||
|
const slab = (data.weight_slab || '').trim();
|
||||||
|
const col = (data.zone || data.service_type || '').trim();
|
||||||
|
return pricingList.find(p =>
|
||||||
|
(p.company || '').trim() === company &&
|
||||||
|
(p.weight_slab || '').trim() === slab &&
|
||||||
|
colKey(p) === col
|
||||||
|
) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const colValue = (formData.zone || formData.service_type || '').trim();
|
||||||
|
if (!isCell) {
|
||||||
|
if (!formData.company?.trim()) { setError('Company is required.'); return; }
|
||||||
|
if (!formData.weight_slab?.trim()) { setError('Weight slab is required.'); return; }
|
||||||
|
if (!colValue) { setError('Zone / Service Type is required.'); return; }
|
||||||
|
}
|
||||||
|
if (!String(formData.rate ?? '').trim()) { setError('Rate is required.'); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let dataToSave = { ...formData };
|
||||||
|
// Route the column value to the right field based on provider type
|
||||||
|
if (!isCell) {
|
||||||
|
dataToSave = {
|
||||||
|
...dataToSave,
|
||||||
|
zone: providerUsesServiceType ? '' : colValue,
|
||||||
|
service_type: providerUsesServiceType ? colValue : (dataToSave.service_type || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!dataToSave.id) {
|
||||||
|
const existing = findExistingRecord(dataToSave);
|
||||||
|
if (existing) dataToSave = { ...dataToSave, id: existing.id };
|
||||||
|
}
|
||||||
|
await apiSavePricing(dataToSave);
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message || 'Failed to save. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Snackbar
|
||||||
|
open={!!error}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
sx={{ zIndex: 9999 }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
{isCell ? (initialData?.id ? 'Edit Rate' : 'Add Rate') : (initialData?.id ? 'Edit Rate' : 'Add New Rate')}
|
||||||
|
<IconButton size="small" onClick={onClose} disabled={saving}><CloseIcon fontSize="small" /></IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{isCell ? (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: alpha('#c01227', 0.04), border: '1px solid', borderColor: alpha('#c01227', 0.15), mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.500', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>UPDATING RATE FOR</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900' }}>{formData.company}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.25 }}>{formData.weight_slab} · {formData.zone || formData.service_type}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField fullWidth label="Company *" value={formData.company || ''} onChange={set('company')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField select fullWidth label="Weight Slab *" value={formData.weight_slab || ''} onChange={set('weight_slab')} size="small">
|
||||||
|
{WEIGHT_SLABS.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
select fullWidth
|
||||||
|
label={providerUsesServiceType ? 'Service Type *' : 'Zone / Service Type *'}
|
||||||
|
value={formData.zone || formData.service_type || ''}
|
||||||
|
onChange={(e) => setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
zone: providerUsesServiceType ? '' : e.target.value,
|
||||||
|
service_type: providerUsesServiceType ? e.target.value : ''
|
||||||
|
}))}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{(providerColumns.length > 0 ? providerColumns : ZONES).map(z => (
|
||||||
|
<MenuItem key={z} value={z}>{z}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Rate (₹) *"
|
||||||
|
value={formData.rate || ''}
|
||||||
|
onChange={set('rate')}
|
||||||
|
size="small"
|
||||||
|
placeholder="e.g. 25 or 25-35"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||||
|
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving} variant="contained" sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2 }}>
|
||||||
|
{saving ? 'Saving...' : 'Save Rate'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetchSurveys() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||||
|
const json = await res.json();
|
||||||
|
const data = Array.isArray(json) ? json : (json.data || []);
|
||||||
|
return { data, total: data.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pricing() {
|
||||||
|
const [pricingList, setPricingList] = useState([]);
|
||||||
|
const [surveysList, setSurveysList] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [locationsOpen, setLocationsOpen] = useState(false);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingRecord, setEditingRecord] = useState(null);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [isCellEdit, setIsCellEdit] = useState(false);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
apiFetchPricing().catch(() => ({ data: [] })),
|
||||||
|
apiFetchSurveys().catch(() => ({ data: [] })),
|
||||||
|
fetchUsers().catch(() => [])
|
||||||
|
]).then(([pRes, sRes, userRes]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPricingList(Array.isArray(pRes) ? pRes : (pRes.data || []));
|
||||||
|
setSurveysList(sRes.data || sRes || []);
|
||||||
|
setUsers(Array.isArray(userRes) ? userRes : (userRes.data || []));
|
||||||
|
}
|
||||||
|
}).catch(err => console.error("Failed to fetch data", err))
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Ensure absolutely unique companies and default selection
|
||||||
|
const companies = [...new Set(pricingList.map(p => p.company).filter(Boolean))];
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProvider && companies.length > 0) {
|
||||||
|
setSelectedProvider(companies[0]);
|
||||||
|
}
|
||||||
|
}, [companies, selectedProvider]);
|
||||||
|
|
||||||
|
const providerRates = pricingList.filter(p => p.company === selectedProvider);
|
||||||
|
// Normalize: lowercase, trim edges, collapse internal spaces
|
||||||
|
const normCompany = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
||||||
|
const normProvider = normCompany(selectedProvider);
|
||||||
|
const providerSurveys = surveysList.filter(p => {
|
||||||
|
const pn = normCompany(p.company);
|
||||||
|
// Exact after normalising, or one name starts with the other (handles "Pvt Ltd" suffixes)
|
||||||
|
return pn === normProvider || pn.startsWith(normProvider) || normProvider.startsWith(pn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by ID desc so most recent record wins when duplicates exist
|
||||||
|
const sortedProviderRates = [...providerRates].sort((a, b) => (b.id || 0) - (a.id || 0));
|
||||||
|
|
||||||
|
// Extract numeric weight from a slab string for sorting rows logically
|
||||||
|
const slabSortWeight = (slab) => {
|
||||||
|
const s = (slab || '').toLowerCase().replace(/\s+/g, '');
|
||||||
|
const nums = s.match(/[\d.]+/g);
|
||||||
|
if (!nums) return 9999;
|
||||||
|
const first = parseFloat(nums[0]);
|
||||||
|
const grams = s.includes('kg') ? first * 1000 : first;
|
||||||
|
if (s.startsWith('<') || s.startsWith('upto')) return grams - 0.5;
|
||||||
|
if (s.startsWith('>') || s.includes('above') || s.endsWith('+')) return grams + 0.5;
|
||||||
|
return grams;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use zone if available, fall back to service_type (e.g. DTDC has service_type instead of zone)
|
||||||
|
const getColKey = (r) => ((r.zone || '').trim() || (r.service_type || '').trim());
|
||||||
|
const providerUsesServiceType = sortedProviderRates.length > 0 &&
|
||||||
|
sortedProviderRates.every(r => !(r.zone || '').trim());
|
||||||
|
|
||||||
|
// Dynamic columns — whichever field the provider uses for differentiation
|
||||||
|
const providerZones = [...new Set(sortedProviderRates.map(getColKey).filter(Boolean))].sort();
|
||||||
|
|
||||||
|
// Dynamic rows: every unique weight slab, sorted by actual weight
|
||||||
|
const allProviderSlabs = [...new Set(sortedProviderRates.map(r => (r.weight_slab || '').trim()).filter(Boolean))]
|
||||||
|
.sort((a, b) => slabSortWeight(a) - slabSortWeight(b));
|
||||||
|
|
||||||
|
const displaySlabs = searchQuery
|
||||||
|
? allProviderSlabs.filter(s => s.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
: allProviderSlabs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 6, minHeight: '80vh', bgcolor: '#f8fafc', px: { xs: 2, md: 4 }, pt: 3 }}>
|
||||||
|
<PageHeader
|
||||||
|
title="Logistics Pricing Board"
|
||||||
|
breadcrumbs={[{ label: 'Pricing Overview' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4, mt: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||||
|
|
||||||
|
{/* Modern Premium Control Panel */}
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
boxShadow: '0 20px 40px -10px rgba(0,0,0,0.03)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: { xs: '100%', md: 'auto' } }}>
|
||||||
|
<Box sx={{ width: 48, height: 48, borderRadius: 3, bgcolor: 'primary.lighter', color: 'primary.main', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<LocalShippingOutlinedIcon />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
||||||
|
Active Logistics Provider
|
||||||
|
</Typography>
|
||||||
|
<FormControl sx={{ mt: 0.5, minWidth: 260, display: 'block' }}>
|
||||||
|
<Select
|
||||||
|
value={selectedProvider || ''}
|
||||||
|
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||||
|
displayEmpty
|
||||||
|
IconComponent={StorefrontOutlinedIcon}
|
||||||
|
input={<OutlinedInput sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
bgcolor: alpha('#e2e8f0', 0.4),
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
'&:hover': { bgcolor: alpha('#e2e8f0', 0.6) },
|
||||||
|
'&.Mui-focused': { bgcolor: '#fff', '& fieldset': { border: '2px solid', borderColor: 'primary.main' } }
|
||||||
|
}} />}
|
||||||
|
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 24px rgba(0,0,0,0.1)' } } }}
|
||||||
|
>
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<MenuItem value="" disabled>Loading Providers...</MenuItem>
|
||||||
|
) : (
|
||||||
|
companies.map(c => (
|
||||||
|
<MenuItem key={c} value={c} sx={{ fontWeight: 600, py: 1.5, borderRadius: 1, mx: 1 }}>{c}</MenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search weight slabs..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><SearchIcon color="primary" /></InputAdornment>,
|
||||||
|
sx: {
|
||||||
|
borderRadius: 50,
|
||||||
|
bgcolor: alpha('#e2e8f0', 0.3),
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
'&:hover': { bgcolor: alpha('#e2e8f0', 0.5) },
|
||||||
|
'&.Mui-focused': { bgcolor: '#fff', '& fieldset': { border: '2px solid', borderColor: 'primary.main' } }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord({ company: selectedProvider, weight_slab: '', zone: '', rate: '' }); setIsCellEdit(false); setFormOpen(true); }} sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2, py: 1.2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
Add Rate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isEditMode ? "contained" : "outlined"}
|
||||||
|
startIcon={<EditOutlinedIcon />}
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, py: 1.2, fontWeight: 700, whiteSpace: 'nowrap',
|
||||||
|
color: isEditMode ? 'white' : 'grey.700',
|
||||||
|
borderColor: isEditMode ? 'transparent' : 'grey.300',
|
||||||
|
bgcolor: isEditMode ? 'grey.800' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: isEditMode ? 'grey.900' : alpha('#000', 0.05), borderColor: isEditMode ? 'transparent' : 'grey.400' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditMode ? 'Done' : 'Edit'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Minimalist Collapsible Provider Profile Widget */}
|
||||||
|
{selectedProvider && providerSurveys.length > 0 && (
|
||||||
|
<Card
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: locationsOpen ? '#c01227' : 'grey.200',
|
||||||
|
bgcolor: 'white',
|
||||||
|
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#c01227',
|
||||||
|
boxShadow: '0 8px 30px rgba(192, 18, 39, 0.08)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }} spacing={4} sx={{ p: { xs: 3, md: 3.5 }, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
|
||||||
|
onClick={() => setLocationsOpen(!locationsOpen)}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Avatar & Title */}
|
||||||
|
<Stack direction="row" spacing={3} alignItems="center" sx={{ minWidth: 260 }}>
|
||||||
|
<Avatar sx={{ width: 68, height: 68, bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 800, fontSize: '1.8rem', borderRadius: 3 }}>
|
||||||
|
{(selectedProvider || 'A')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 1.2 }}>LOGISTICS PARTNER</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 800, color: 'grey.900', lineHeight: 1.1, mt: 0.25 }}>{selectedProvider}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.600', mt: 1, fontWeight: 500, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 16, mr: 0.5, color: '#c01227' }} />
|
||||||
|
{providerSurveys.length} Registered Location{providerSurveys.length !== 1 && 's'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Badges & Action */}
|
||||||
|
<Stack direction="row" spacing={3} alignItems="center">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', md: 'flex' } }}>
|
||||||
|
<Chip size="small" label="First Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
<Chip size="small" label="Mid Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
<Chip size="small" label="Last Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLocationsOpen(!locationsOpen); }}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
|
||||||
|
color: locationsOpen ? '#c01227' : 'grey.700',
|
||||||
|
bgcolor: locationsOpen ? alpha('#c01227', 0.1) : 'grey.100',
|
||||||
|
'&:hover': { bgcolor: alpha('#c01227', 0.15), color: '#c01227' }
|
||||||
|
}}
|
||||||
|
endIcon={locationsOpen ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||||
|
>
|
||||||
|
{locationsOpen ? 'Hide Coverage' : 'View Coverage Map'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={locationsOpen} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ p: 3, pt: 0, borderTop: '1px dashed', borderColor: 'grey.200', bgcolor: 'grey.50', borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, mt: 3 }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ color: '#c01227', fontSize: 20, mr: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'grey.600', fontWeight: 700, letterSpacing: 1 }}>ALL SERVICED AREAS:</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
{providerSurveys.map((s, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={s.id ?? idx}
|
||||||
|
label={s.area || s.address}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, fontWeight: 600, px: 1, py: 2.5,
|
||||||
|
bgcolor: 'white', color: 'grey.800', border: '1px solid', borderColor: 'grey.300', boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
|
||||||
|
'&:hover': { borderColor: '#c01227', color: '#c01227', bgcolor: alpha('#c01227', 0.05), boxShadow: '0 4px 12px rgba(192,18,39,0.08)' },
|
||||||
|
transition: 'border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pricing Data Grid */}
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.05)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 10 }}>
|
||||||
|
<CircularProgress size={48} thickness={4} sx={{ color: 'primary.light' }} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table sx={{ minWidth: 700 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: alpha('#f8fafc', 0.8) }}>
|
||||||
|
<TableCell sx={{ py: 2, px: 4, fontWeight: 700, color: 'grey.500', fontSize: '0.75rem', letterSpacing: 1.2, textTransform: 'uppercase', borderBottom: '2px solid', borderColor: 'grey.100' }}>Weight Slab</TableCell>
|
||||||
|
{providerZones.map(zone => (
|
||||||
|
<TableCell key={zone} align="center" sx={{ py: 2, fontWeight: 700, color: 'grey.500', fontSize: '0.75rem', letterSpacing: 1.2, textTransform: 'uppercase', borderBottom: '2px solid', borderColor: 'grey.100' }}>
|
||||||
|
{zone}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{providerZones.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} align="center" sx={{ py: 8 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||||
|
No pricing data for this provider yet.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : displaySlabs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={providerZones.length + 1} align="center" sx={{ py: 8 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||||
|
No weight slabs match your search.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
displaySlabs.map((slab) => (
|
||||||
|
<TableRow
|
||||||
|
key={slab}
|
||||||
|
sx={{
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
'&:hover': { bgcolor: alpha('#f1f5f9', 0.5) },
|
||||||
|
'&:last-child td': { borderBottom: 0 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ px: 4, py: 2, fontWeight: 600, color: 'grey.800', fontSize: '0.9rem', borderBottom: '1px solid', borderColor: 'grey.100' }}>
|
||||||
|
{slab}
|
||||||
|
</TableCell>
|
||||||
|
{providerZones.map(zone => {
|
||||||
|
// Exact match — use getColKey so DTDC service_type columns work too
|
||||||
|
const match = sortedProviderRates.find(r => (r.weight_slab || '').trim() === slab && getColKey(r) === zone);
|
||||||
|
const hasRate = !!match;
|
||||||
|
const rateStr = match ? `₹${match.rate}` : '—';
|
||||||
|
|
||||||
|
let tooltipText = '';
|
||||||
|
if (match && match.updated_by) {
|
||||||
|
const updater = users.find(u => u.id === match.updated_by);
|
||||||
|
tooltipText = `Last updated by: ${updater ? updater.first_name : 'ID ' + match.updated_by}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCellEdit = () => {
|
||||||
|
setEditingRecord({
|
||||||
|
id: match ? match.id : undefined,
|
||||||
|
company: selectedProvider,
|
||||||
|
weight_slab: slab,
|
||||||
|
zone: providerUsesServiceType ? '' : zone,
|
||||||
|
service_type: providerUsesServiceType ? zone : (match?.service_type || ''),
|
||||||
|
rate: match ? String(match.rate) : ''
|
||||||
|
});
|
||||||
|
setIsCellEdit(true);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={zone}
|
||||||
|
align="center"
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
cursor: isEditMode ? 'pointer' : 'default',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
'&:hover': isEditMode ? { bgcolor: alpha('#c01227', 0.04) } : {},
|
||||||
|
}}
|
||||||
|
onClick={isEditMode ? openCellEdit : undefined}
|
||||||
|
>
|
||||||
|
<Tooltip title={isEditMode ? (hasRate ? 'Click to edit rate' : 'Click to add rate') : tooltipText} placement="top" arrow disableHoverListener={!isEditMode && !tooltipText}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontWeight: hasRate ? 700 : 400,
|
||||||
|
color: hasRate ? 'success.dark' : (isEditMode ? alpha('#c01227', 0.4) : 'grey.300'),
|
||||||
|
fontSize: hasRate ? '0.95rem' : '0.85rem',
|
||||||
|
bgcolor: hasRate ? alpha('#10b981', 0.08) : 'transparent',
|
||||||
|
px: hasRate ? 1.5 : 0,
|
||||||
|
py: hasRate ? 0.5 : 0,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rateStr}
|
||||||
|
</Typography>
|
||||||
|
{isEditMode && (
|
||||||
|
hasRate
|
||||||
|
? <EditOutlinedIcon sx={{ fontSize: 13, color: 'grey.400' }} />
|
||||||
|
: <AddCircleOutlineIcon sx={{ fontSize: 13, color: alpha('#c01227', 0.4) }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<PricingFormDialog
|
||||||
|
open={formOpen}
|
||||||
|
onClose={() => setFormOpen(false)}
|
||||||
|
onSave={() => loadData()}
|
||||||
|
initialData={editingRecord}
|
||||||
|
isCell={isCellEdit}
|
||||||
|
pricingList={pricingList}
|
||||||
|
providerColumns={providerZones}
|
||||||
|
providerUsesServiceType={providerUsesServiceType}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
895
src/pages/survey/Survey.jsx
Normal file
895
src/pages/survey/Survey.jsx
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
import { useState, useEffect, Fragment } from 'react';
|
||||||
|
import { fetchUsers, deleteCompetitorBranch } from '@/utils/apiClient';
|
||||||
|
import {
|
||||||
|
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
|
Typography, TextField, InputAdornment, IconButton, Collapse, TablePagination, Stack, CircularProgress, alpha,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, MenuItem, Alert, Autocomplete, Snackbar
|
||||||
|
} from '@mui/material';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||||
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
|
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||||
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||||
|
import { Avatar, Button, Chip, Grid } from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import StatCard from '@/components/StatCard';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_LABEL_SX = { textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700, color: 'grey.700' };
|
||||||
|
|
||||||
|
function Pill({ label, color = 'default' }) {
|
||||||
|
if (!label) return <Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.500' }}>—</Typography>;
|
||||||
|
return (
|
||||||
|
<Chip size="small" label={label} sx={{ fontWeight: 600, ...(color === 'default' ? { bgcolor: 'grey.100', color: 'grey.700' } : { bgcolor: `${color}.lighter`, color: `${color}.dark` }) }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={FIELD_LABEL_SX}>{label}</Typography>
|
||||||
|
<Box sx={{ mt: 0.5 }}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MagicStatCard({ title, value, icon: Icon, gradient, percentage }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 3.5,
|
||||||
|
p: 3,
|
||||||
|
color: 'white',
|
||||||
|
background: gradient,
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
|
||||||
|
transition: 'box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 20px 40px rgba(0,0,0,0.12)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ position: 'relative', zIndex: 2 }}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="overline" sx={{ fontWeight: 700, letterSpacing: 1.2, opacity: 0.9 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ width: 36, height: 36, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
|
||||||
|
<Icon sx={{ fontSize: 20 }} />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" alignItems="baseline" spacing={1.5} sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="h3" sx={{ fontWeight: 800, lineHeight: 1 }}>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
{percentage && (
|
||||||
|
<Chip size="small" label={percentage} sx={{ height: 22, bgcolor: 'rgba(255,255,255,0.25)', color: 'white', fontWeight: 700, borderRadius: 1.5, backdropFilter: 'blur(4px)' }} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Icon
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -24,
|
||||||
|
bottom: -24,
|
||||||
|
fontSize: 160,
|
||||||
|
opacity: 0.1,
|
||||||
|
zIndex: 1,
|
||||||
|
transform: 'rotate(-15deg)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ icon: Icon, title, children }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', borderRadius: 2, border: 1, borderColor: 'divider', bgcolor: 'background.paper', overflow: 'hidden' }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ px: 2, py: 1.25, borderBottom: 1, borderColor: 'divider', bgcolor: alpha('#c01227', 0.08) }}>
|
||||||
|
<Icon sx={{ fontSize: 18, color: '#c01227' }} />
|
||||||
|
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={2} sx={{ p: 2 }}>{children}</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetchSurveys() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||||
|
const json = await res.json();
|
||||||
|
const data = Array.isArray(json) ? json : (json.data || []);
|
||||||
|
return { data, total: data.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiSaveSurvey(data) {
|
||||||
|
const isUpdate = !!data.id;
|
||||||
|
const url = isUpdate ? `${API_BASE}/admin/competitor-branches/${data.id}` : `${API_BASE}/admin/competitor-branches`;
|
||||||
|
const method = isUpdate ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to save survey');
|
||||||
|
}
|
||||||
|
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiSavePricing(data) {
|
||||||
|
const isUpdate = !!data.id;
|
||||||
|
const url = isUpdate ? `${API_BASE}/admin/carrier-pricing/${data.id}` : `${API_BASE}/admin/carrier-pricing`;
|
||||||
|
const method = isUpdate ? 'PUT' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
company: data.company,
|
||||||
|
weight_slab: data.weight_slab,
|
||||||
|
zone: data.zone || '',
|
||||||
|
service_type: data.service_type || '',
|
||||||
|
rate: String(data.rate)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to save pricing');
|
||||||
|
}
|
||||||
|
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
||||||
|
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
||||||
|
|
||||||
|
function SurveyFormDialog({ open, onClose, onSave, initialData }) {
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFormData(initialData ? {
|
||||||
|
...initialData,
|
||||||
|
pincodes: initialData.pincodes ? initialData.pincodes.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||||
|
} : {
|
||||||
|
company: '', area: '', phone: '', rate_per_kg: '',
|
||||||
|
offers_pickup: 'no', offers_drop: 'no', packing_charge: '',
|
||||||
|
time_in_days: '', plus_code: '', address: '', frequency: '',
|
||||||
|
pincodes: [],
|
||||||
|
slabs: []
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, initialData]);
|
||||||
|
|
||||||
|
const handleChange = (field) => (e) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlabChange = (index, field) => (e) => {
|
||||||
|
const newSlabs = [...(formData.slabs || [])];
|
||||||
|
newSlabs[index] = { ...newSlabs[index], [field]: e.target.value };
|
||||||
|
setFormData(prev => ({ ...prev, slabs: newSlabs }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSlab = () => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
slabs: [...(prev.slabs || []), { weight_slab: '', zone: '', service_type: '', rate: '' }]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlab = (index) => {
|
||||||
|
const newSlabs = [...(formData.slabs || [])];
|
||||||
|
newSlabs.splice(index, 1);
|
||||||
|
setFormData(prev => ({ ...prev, slabs: newSlabs }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePincodeChange = (index) => (e) => {
|
||||||
|
const newPincodes = [...(formData.pincodes || [])];
|
||||||
|
newPincodes[index] = e.target.value;
|
||||||
|
setFormData(prev => ({ ...prev, pincodes: newPincodes }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPincode = () => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
pincodes: [...(prev.pincodes || []), '']
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePincode = (index) => {
|
||||||
|
const newPincodes = [...(formData.pincodes || [])];
|
||||||
|
newPincodes.splice(index, 1);
|
||||||
|
setFormData(prev => ({ ...prev, pincodes: newPincodes }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (formData.pincodes && formData.pincodes.length > 0) {
|
||||||
|
const invalidPincodes = formData.pincodes.filter(p => p.trim() && !/^\d{6}$/.test(p.trim()));
|
||||||
|
if (invalidPincodes.length > 0) {
|
||||||
|
throw new Error(`Invalid pincodes detected: ${invalidPincodes.join(', ')}. All pincodes must be exactly 6 numeric digits.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...(formData.id ? { id: formData.id } : {}),
|
||||||
|
company: formData.company || '',
|
||||||
|
area: formData.area || '',
|
||||||
|
phone: formData.phone || '',
|
||||||
|
rate_per_kg: formData.rate_per_kg || '',
|
||||||
|
offers_pickup: formData.offers_pickup || 'no',
|
||||||
|
offers_drop: formData.offers_drop || 'no',
|
||||||
|
packing_charge: formData.packing_charge || '',
|
||||||
|
time_in_days: formData.time_in_days || '',
|
||||||
|
plus_code: formData.plus_code || '',
|
||||||
|
address: formData.address || '',
|
||||||
|
frequency: formData.frequency || '',
|
||||||
|
pincodes: formData.pincodes ? formData.pincodes.join(',') : '',
|
||||||
|
};
|
||||||
|
await apiSaveSurvey(payload);
|
||||||
|
|
||||||
|
if (formData.slabs && formData.slabs.length > 0 && formData.company) {
|
||||||
|
try {
|
||||||
|
await Promise.all(formData.slabs.map(slab => {
|
||||||
|
if (slab.weight_slab && slab.rate) {
|
||||||
|
return apiSavePricing({
|
||||||
|
company: formData.company,
|
||||||
|
weight_slab: slab.weight_slab,
|
||||||
|
zone: slab.zone || '',
|
||||||
|
service_type: slab.service_type || '',
|
||||||
|
rate: slab.rate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save pricing slabs", err);
|
||||||
|
// Continue anyway since survey was saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err.message || 'Failed to save. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Snackbar
|
||||||
|
open={!!error}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
sx={{ zIndex: 9999 }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>{initialData ? 'Edit Survey Record' : 'Add New Survey'}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Company" value={formData.company || ''} onChange={handleChange('company')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Area / Zone" value={formData.area || ''} onChange={handleChange('area')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Phone" value={formData.phone || ''} onChange={handleChange('phone')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Rate Per KG" value={formData.rate_per_kg || ''} onChange={handleChange('rate_per_kg')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField select fullWidth label="Offers Pickup" value={formData.offers_pickup || 'no'} onChange={handleChange('offers_pickup')} size="small">
|
||||||
|
<MenuItem value="yes">Yes</MenuItem>
|
||||||
|
<MenuItem value="no">No</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField select fullWidth label="Offers Drop" value={formData.offers_drop || 'no'} onChange={handleChange('offers_drop')} size="small">
|
||||||
|
<MenuItem value="yes">Yes</MenuItem>
|
||||||
|
<MenuItem value="no">No</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Time in Days" value={formData.time_in_days || ''} onChange={handleChange('time_in_days')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField fullWidth label="Packing Charge" value={formData.packing_charge || ''} onChange={handleChange('packing_charge')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField select fullWidth label="Frequency" value={formData.frequency || ''} onChange={handleChange('frequency')} size="small">
|
||||||
|
<MenuItem value="Daily">Daily</MenuItem>
|
||||||
|
<MenuItem value="Weekly">Weekly</MenuItem>
|
||||||
|
<MenuItem value="Bi-weekly">Bi-weekly</MenuItem>
|
||||||
|
<MenuItem value="Monthly">Monthly</MenuItem>
|
||||||
|
<MenuItem value="On-demand">On-demand</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField fullWidth label="Plus Code" value={formData.plus_code || ''} onChange={handleChange('plus_code')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box sx={{ mt: 1, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', letterSpacing: 0.5 }}>SERVICEABLE PINCODES</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addPincode} sx={{ fontWeight: 600 }}>Add Pincode</Button>
|
||||||
|
</Box>
|
||||||
|
{(formData.pincodes || []).map((pincode, index) => (
|
||||||
|
<Grid container spacing={1.5} key={index} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<Grid item xs={10} sm={11}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={`Pincode ${index + 1}`}
|
||||||
|
value={pincode}
|
||||||
|
onChange={handlePincodeChange(index)}
|
||||||
|
size="small"
|
||||||
|
placeholder="e.g. 600001"
|
||||||
|
inputProps={{ maxLength: 6 }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={2} sm={1}>
|
||||||
|
<IconButton size="small" color="error" onClick={() => removePincode(index)}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
{(!formData.pincodes || formData.pincodes.length === 0) && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2, bgcolor: 'grey.50', borderRadius: 1, border: '1px dashed', borderColor: 'grey.300' }}>
|
||||||
|
Click 'Add Pincode' to assign locations to this competitor.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField fullWidth multiline rows={2} label="Address" value={formData.address || ''} onChange={handleChange('address')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box sx={{ mt: 1, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', letterSpacing: 0.5 }}>PRICING SLABS (OPTIONAL)</Typography>
|
||||||
|
<Button size="small" startIcon={<AddIcon />} onClick={addSlab} sx={{ fontWeight: 600 }}>Add Slab</Button>
|
||||||
|
</Box>
|
||||||
|
{(formData.slabs || []).map((slab, index) => (
|
||||||
|
<Grid container spacing={1.5} key={index} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<TextField select fullWidth label="Weight Slab" value={slab.weight_slab || ''} onChange={handleSlabChange(index, 'weight_slab')} size="small">
|
||||||
|
{WEIGHT_SLABS.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<TextField select fullWidth label="Zone" value={slab.zone || ''} onChange={handleSlabChange(index, 'zone')} size="small">
|
||||||
|
{ZONES.map(z => <MenuItem key={z} value={z}>{z}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} sm={3}>
|
||||||
|
<TextField fullWidth label="Service Type" value={slab.service_type || ''} onChange={handleSlabChange(index, 'service_type')} size="small" placeholder="e.g. Air" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4} sm={2}>
|
||||||
|
<TextField fullWidth label="Rate (₹)" value={slab.rate || ''} onChange={handleSlabChange(index, 'rate')} size="small" placeholder="e.g. 25" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={2} sm={1}>
|
||||||
|
<IconButton size="small" color="error" onClick={() => removeSlab(index)}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
{(!formData.slabs || formData.slabs.length === 0) && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2, bgcolor: 'grey.50', borderRadius: 1, border: '1px dashed', borderColor: 'grey.300' }}>
|
||||||
|
Click 'Add Slab' to add pricing entries for this competitor.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||||
|
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving} variant="contained" sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2 }}>
|
||||||
|
{saving ? 'Saving...' : 'Save Record'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BranchCard({ row, onEdit, onDelete, users }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const creator = users.find(u => u.id === row.created_by);
|
||||||
|
const updater = users.find(u => u.id === row.updated_by);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2, borderRadius: 2.5, border: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', transition: 'border-color 0.2s ease, box-shadow 0.2s ease', '&:hover': { borderColor: '#c01227', boxShadow: '0 4px 12px rgba(192, 18, 39, 0.05)' } }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Left: Location & Contact */}
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center" sx={{ minWidth: 240 }}>
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: alpha('#c01227', 0.05) }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ color: '#c01227' }} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.900', lineHeight: 1.2 }}>
|
||||||
|
{row.area || 'Unknown Area'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.600', display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||||
|
<PhoneOutlinedIcon sx={{ fontSize: 14 }} /> {row.phone || '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Middle: Rates */}
|
||||||
|
<Stack direction="row" spacing={4} sx={{ flex: 1 }} justifyContent={{ xs: 'flex-start', md: 'center' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 0.5, display: 'block', mb: 0.5 }}>RATE PER KG</Typography>
|
||||||
|
<Pill label={row.rate_per_kg} color="success" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 0.5, display: 'block', mb: 0.5 }}>LOGISTICS</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>
|
||||||
|
{row.offers_pickup === 'yes' || row.offers_drop === 'yes' ? `${row.offers_pickup === 'yes' ? 'Pickup' : ''} ${row.offers_drop === 'yes' ? 'Drop' : ''}` : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Button size="small" sx={{ borderRadius: 1.5, color: open ? '#c01227' : 'grey.700', bgcolor: open ? alpha('#c01227', 0.05) : 'transparent', '&:hover': { bgcolor: alpha('#c01227', 0.1), color: '#c01227' } }}>
|
||||||
|
{open ? 'Hide Info' : 'More Info'}
|
||||||
|
</Button>
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }} sx={{ color: 'grey.400', '&:hover': { color: '#c01227', bgcolor: alpha('#c01227', 0.1) } }}>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }} sx={{ color: 'grey.400', '&:hover': { color: '#ef4444', bgcolor: alpha('#ef4444', 0.1) } }}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ mt: 2.5, pt: 2.5, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||||
|
<Grid container spacing={2} alignItems="stretch">
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<SectionCard icon={StorefrontOutlinedIcon} title="ENQUIRY DETAILS">
|
||||||
|
<Field label="RATE PER KG">
|
||||||
|
<Pill label={row.rate_per_kg} color="success" />
|
||||||
|
</Field>
|
||||||
|
<Field label="PICKUP">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: row.offers_pickup ? 'grey.800' : 'grey.500' }}>{row.offers_pickup || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="DROP">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: row.offers_drop ? 'grey.800' : 'grey.500' }}>{row.offers_drop || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="PACKING">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: row.packing_charge ? 'grey.800' : 'grey.500' }}>{row.packing_charge || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<SectionCard icon={LocalShippingOutlinedIcon} title="LOGISTICS & OPS">
|
||||||
|
<Field label="TIME IN DAYS">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: row.time_in_days ? 'grey.800' : 'grey.500' }}>{row.time_in_days || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="COMPANY">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.company || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="FREQUENCY">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.frequency || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="CONTACT NUMBER">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.phone || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<SectionCard icon={PlaceOutlinedIcon} title="LOCATION & PINCODE">
|
||||||
|
<Field label="AREA / ZONE">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.area || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="SERVICEABLE PINCODES">
|
||||||
|
{row.pincodes ? (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
|
||||||
|
{row.pincodes.split(',').map((p, i) => (
|
||||||
|
<Chip key={i} label={p.trim()} size="small" sx={{ fontSize: '0.7rem', height: 20 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.500' }}>—</Typography>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="PLUS CODE">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: row.plus_code ? 'grey.800' : 'grey.500' }}>{row.plus_code || '—'}</Typography>
|
||||||
|
</Field>
|
||||||
|
<Field label="FULL ADDRESS">
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'grey.50', border: 1, borderColor: 'divider' }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', mt: '2px' }} />
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.800', lineHeight: 1.5 }}>{row.address || '—'}</Typography>
|
||||||
|
</Stack>
|
||||||
|
{(row.address || row.plus_code) && (
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(row.plus_code ? row.plus_code + ' ' + (row.address||'') : row.address)}`}
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
size="small" variant="outlined" startIcon={<MapOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
py: 0.5, px: 1.5, fontSize: '0.75rem', fontWeight: 600, borderRadius: 2,
|
||||||
|
color: '#c01227', borderColor: alpha('#c01227', 0.5), bgcolor: alpha('#c01227', 0.05),
|
||||||
|
'&:hover': { borderColor: '#c01227', bgcolor: alpha('#c01227', 0.1) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View on map
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Field>
|
||||||
|
</SectionCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<SectionCard icon={AssignmentOutlinedIcon} title="RECORD METADATA">
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Field label="CREATED BY">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>
|
||||||
|
{creator ? creator.first_name : (row.created_by ? `User ID: ${row.created_by}` : 'System')}
|
||||||
|
</Typography>
|
||||||
|
</Field>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Field label="LAST EDITED BY">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>
|
||||||
|
{updater ? updater.first_name : (row.updated_by ? `User ID: ${row.updated_by}` : '—')}
|
||||||
|
</Typography>
|
||||||
|
</Field>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</SectionCard>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyGroup({ companyName, branches, onEdit, onDelete, users }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: open ? '#c01227' : 'grey.200',
|
||||||
|
p: { xs: 2.5, md: 3 },
|
||||||
|
mb: 2.5,
|
||||||
|
transition: 'border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#c01227',
|
||||||
|
boxShadow: '0 8px 30px rgba(192, 18, 39, 0.08)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Left Side: Avatar & Basic Info */}
|
||||||
|
<Stack direction="row" spacing={2.5} alignItems="center">
|
||||||
|
<Avatar sx={{ width: 64, height: 64, bgcolor: alpha('#c01227', 0.1), color: '#c01227', fontWeight: 800, fontSize: '1.5rem', borderRadius: 2.5 }}>
|
||||||
|
{(companyName || 'A')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 800, lineHeight: 1.2, color: 'grey.900' }}>
|
||||||
|
{companyName || 'Unknown Company'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.5, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<StorefrontOutlinedIcon sx={{ fontSize: 16 }} /> {branches.length} Location{branches.length !== 1 && 's'} Surveyed
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Right Side: Badges & Actions */}
|
||||||
|
<Stack direction="row" spacing={3} alignItems="center">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', md: 'flex' } }}>
|
||||||
|
<Chip size="small" label="First Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
<Chip size="small" label="Mid Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
<Chip size="small" label="Last Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Button
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
|
||||||
|
color: open ? '#c01227' : 'grey.700',
|
||||||
|
bgcolor: open ? alpha('#c01227', 0.1) : 'grey.100',
|
||||||
|
'&:hover': { bgcolor: alpha('#c01227', 0.15), color: '#c01227' }
|
||||||
|
}}
|
||||||
|
endIcon={open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||||
|
>
|
||||||
|
{open ? 'Hide Locations' : 'View Locations'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ mt: 3, pt: 3, borderTop: '2px dashed', borderColor: 'grey.200' }}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{branches.map((branch, idx) => <BranchCard key={branch.id ?? idx} row={branch} onEdit={onEdit} onDelete={onDelete} users={users} />)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Survey() {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingRecord, setEditingRecord] = useState(null);
|
||||||
|
const [toDelete, setToDelete] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState(null);
|
||||||
|
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
apiFetchSurveys(),
|
||||||
|
fetchUsers().catch(() => [])
|
||||||
|
])
|
||||||
|
.then(([res, userRes]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(res.data || res || []);
|
||||||
|
setUsers(userRes || []);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error("Error fetching data:", err))
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
try {
|
||||||
|
await deleteCompetitorBranch(toDelete.id);
|
||||||
|
setToDelete(null);
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
setDeleteError(e.message || 'Failed to delete. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = data.filter(r =>
|
||||||
|
(r.company || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(r.area || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(r.phone || '').toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group filtered records by company
|
||||||
|
const groupedData = filtered.reduce((acc, row) => {
|
||||||
|
const comp = row.company || 'Unknown Company';
|
||||||
|
if (!acc[comp]) acc[comp] = [];
|
||||||
|
acc[comp].push(row);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const companyKeys = Object.keys(groupedData).sort();
|
||||||
|
const paginatedKeys = companyKeys.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: data.length,
|
||||||
|
quoted: data.filter(d => d.rate_per_kg && !String(d.rate_per_kg).toLowerCase().includes('not answered')).length,
|
||||||
|
missing: data.filter(d => !d.rate_per_kg || String(d.rate_per_kg).toLowerCase().includes('not answered')).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 6, bgcolor: '#f8fafc', px: { xs: 2, md: 4 }, pt: 3, minHeight: '100vh' }}>
|
||||||
|
<PageHeader
|
||||||
|
title="Field Surveys"
|
||||||
|
breadcrumbs={[{ label: 'Survey Management' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid container spacing={3} sx={{ mb: 5, mt: 1 }}>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<MagicStatCard
|
||||||
|
title="TOTAL ENQUIRIES"
|
||||||
|
value={loading ? '...' : stats.total}
|
||||||
|
icon={AssignmentOutlinedIcon}
|
||||||
|
gradient="linear-gradient(135deg, #e11d48 0%, #c01227 100%)"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<MagicStatCard
|
||||||
|
title="RATES QUOTED"
|
||||||
|
value={loading ? '...' : stats.quoted}
|
||||||
|
icon={StorefrontOutlinedIcon}
|
||||||
|
gradient="linear-gradient(135deg, #10b981 0%, #047857 100%)"
|
||||||
|
percentage={stats.total ? `${Math.round((stats.quoted / stats.total) * 100)}%` : '0%'}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<MagicStatCard
|
||||||
|
title="MISSING INFO"
|
||||||
|
value={loading ? '...' : stats.missing}
|
||||||
|
icon={PhoneOutlinedIcon}
|
||||||
|
gradient="linear-gradient(135deg, #334155 0%, #0f172a 100%)"
|
||||||
|
percentage={stats.total ? `${Math.round((stats.missing / stats.total) * 100)}%` : '0%'}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Card sx={{ borderRadius: 2, boxShadow: '0 4px 20px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||||
|
|
||||||
|
{/* Header Section */}
|
||||||
|
<Box sx={{ p: 3, display: 'flex', alignItems: 'center', gap: 2, borderBottom: '1px solid', borderColor: 'grey.100' }}>
|
||||||
|
<Box sx={{
|
||||||
|
width: 48, height: 48, borderRadius: 2.5,
|
||||||
|
bgcolor: alpha('#c01227', 0.1), color: '#c01227',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<AssignmentOutlinedIcon />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.900' }}>
|
||||||
|
Survey Records
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.5 }}>
|
||||||
|
Manage business enquiry and survey data from the field
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Toolbar Section */}
|
||||||
|
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'grey.100', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Search by company, area or phone..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
|
sx={{ width: { xs: '100%', md: 400 } }}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
{!loading && (
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.500', fontWeight: 600 }}>
|
||||||
|
{filtered.length} records
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord(null); setFormOpen(true); }} sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2, px: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
Add New Survey
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* List Section */}
|
||||||
|
<Box sx={{ p: 3, bgcolor: 'grey.50' }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : paginatedKeys.length > 0 ? (
|
||||||
|
<Box>
|
||||||
|
{paginatedKeys.map((companyName) => (
|
||||||
|
<CompanyGroup
|
||||||
|
key={companyName}
|
||||||
|
companyName={companyName}
|
||||||
|
branches={groupedData[companyName]}
|
||||||
|
onEdit={(row) => { setEditingRecord(row); setFormOpen(true); }}
|
||||||
|
onDelete={(row) => setToDelete(row)}
|
||||||
|
users={users}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ py: 10, textAlign: 'center' }}>
|
||||||
|
<AssignmentOutlinedIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary">No survey records found.</Typography>
|
||||||
|
<Typography variant="body2" color="text.disabled" sx={{ mt: 1 }}>Try adjusting your search filters.</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Pagination Section */}
|
||||||
|
<Box sx={{ borderTop: '1px solid', borderColor: 'grey.200', bgcolor: 'white' }}>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={companyKeys.length}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_, newPage) => setPage(newPage)}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
|
||||||
|
rowsPerPageOptions={[5, 10, 25]}
|
||||||
|
labelRowsPerPage="Companies per page:"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SurveyFormDialog
|
||||||
|
open={formOpen}
|
||||||
|
onClose={() => setFormOpen(false)}
|
||||||
|
onSave={() => loadData()}
|
||||||
|
initialData={editingRecord}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => { setToDelete(null); setDeleteError(null); }}>
|
||||||
|
<DialogTitle>Delete survey record?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
This will permanently remove the record for <strong>{toDelete?.area || toDelete?.company}</strong>. This cannot be undone.
|
||||||
|
</DialogContentText>
|
||||||
|
{deleteError && <Alert severity="error" sx={{ mt: 2 }}>{deleteError}</Alert>}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={() => { setToDelete(null); setDeleteError(null); }} disabled={deleting} color="inherit">Cancel</Button>
|
||||||
|
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/pages/survey/data.json
Normal file
1
src/pages/survey/data.json
Normal file
File diff suppressed because one or more lines are too long
@@ -24,24 +24,11 @@ import StatusChip from '@/components/StatusChip';
|
|||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import TabLabelCount from '@/components/TabLabelCount';
|
import TabLabelCount from '@/components/TabLabelCount';
|
||||||
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
|
import { fetchUsers, deleteUser } from '@/utils/apiClient';
|
||||||
|
import { toUser } from '@/utils/mappers';
|
||||||
|
import { titleCase } from '@/utils/format';
|
||||||
import UserFormDialog from './UserFormDialog';
|
import UserFormDialog from './UserFormDialog';
|
||||||
|
|
||||||
// Map a raw Qdrant point from doormile_auth to a flat team-user row.
|
|
||||||
function toUser(point) {
|
|
||||||
const p = point.payload || {};
|
|
||||||
return {
|
|
||||||
id: point.id,
|
|
||||||
name: p.name || '—',
|
|
||||||
email: p.email || '',
|
|
||||||
phone: p.phone || '',
|
|
||||||
role: p.role || 'unknown',
|
|
||||||
pin: p.pin || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleCase = (s) => String(s || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
// Per-role accent colour + icon, used for the avatar badge and role chip.
|
// Per-role accent colour + icon, used for the avatar badge and role chip.
|
||||||
const ROLE_META = {
|
const ROLE_META = {
|
||||||
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
|
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
|
||||||
@@ -111,7 +98,7 @@ export default function TeamUsers() {
|
|||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchPoints(COLLECTIONS.teamUsers)
|
fetchUsers()
|
||||||
.then((points) => setUsers(points.map(toUser)))
|
.then((points) => setUsers(points.map(toUser)))
|
||||||
.catch((e) => setError(e.message || 'Failed to load team users'))
|
.catch((e) => setError(e.message || 'Failed to load team users'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -151,7 +138,7 @@ export default function TeamUsers() {
|
|||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deletePoint(COLLECTIONS.teamUsers, toDelete.id);
|
await deleteUser(toDelete.id);
|
||||||
setToDelete(null);
|
setToDelete(null);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -203,7 +190,7 @@ export default function TeamUsers() {
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{filtered.length} {filtered.length === 1 ? 'user' : 'users'}
|
{filtered.length} {filtered.length === 1 ? 'user' : 'users'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip size="small" label="live · doormile_auth" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
|
<Chip size="small" label="live · PostgreSQL" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -291,7 +278,7 @@ export default function TeamUsers() {
|
|||||||
<DialogTitle>Delete user?</DialogTitle>
|
<DialogTitle>Delete user?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_auth collection. This cannot be undone.
|
This will permanently remove <strong>{toDelete?.name}</strong> from the PostgreSQL database. This cannot be undone.
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
MenuItem, Alert, CircularProgress, IconButton
|
MenuItem, Alert, CircularProgress, IconButton
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant';
|
import { createUser, updateUser } from '@/utils/apiClient';
|
||||||
|
|
||||||
const ROLES = ['admin', 'rep', 'manager'];
|
const ROLES = ['admin', 'rep', 'manager'];
|
||||||
|
|
||||||
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '' };
|
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' };
|
||||||
|
|
||||||
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
||||||
|
|
||||||
@@ -30,22 +30,24 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.name.trim()) { setError('Name is required.'); return; }
|
if (!form.name.trim()) { setError('Name is required.'); return; }
|
||||||
if (!form.email.trim()) { setError('Email is required.'); return; }
|
if (!form.email.trim()) { setError('Email is required.'); return; }
|
||||||
|
if (!isEdit && !form.password.trim()) { setError('Password is required.'); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
first_name: form.name.trim(),
|
||||||
email: form.email.trim(),
|
email: form.email.trim(),
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
role: form.role,
|
role: form.role,
|
||||||
...(form.pin ? { pin: String(form.pin) } : {})
|
...(form.password ? { password: form.password } : {}),
|
||||||
|
...(form.pin ? { pin: String(form.pin) } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await setPayload(COLLECTIONS.teamUsers, initial.id, payload);
|
await updateUser(initial.id, payload);
|
||||||
} else {
|
} else {
|
||||||
await createPoint(COLLECTIONS.teamUsers, payload);
|
await createUser(payload);
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -73,6 +75,14 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
|
|||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth size="small" type="password"
|
||||||
|
label={isEdit ? 'New Password (optional)' : 'Password *'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={set('password')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
|||||||
@@ -1,24 +1,48 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||||
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton
|
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton, Autocomplete, Snackbar
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant';
|
import MyLocationIcon from '@mui/icons-material/MyLocation';
|
||||||
|
import { createClient, updateClient } from '@/utils/apiClient';
|
||||||
|
|
||||||
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
|
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
|
||||||
const STATUSES = ['newClient', 'contacted', 'onboarded', 'lost'];
|
const STATUSES = ['newClient', 'contacted', 'onboarded', 'lost'];
|
||||||
const FREQUENCIES = ['Daily', 'Weekly', 'Fortnightly', 'Monthly', 'Occasional'];
|
const FREQUENCIES = ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'On-demand'];
|
||||||
const CONSENTS = ['basicOnly', 'full', 'none'];
|
const CONSENTS = ['basicOnly', 'full', 'none'];
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
'Blue Dart', 'Delhivery', 'DTDC', 'India Post / Speed Post', 'The Professional Couriers', 'XpressBees', 'Ecom Express', 'Shadowfax',
|
||||||
|
'Safexpress', 'VRL Logistics', 'TCI (Transport Corporation of India)', 'Om Logistics', 'Best Roadways',
|
||||||
|
'MSS (Mettur Super Services)', 'ABT Travels & Logistics', 'Navata Road Transport', 'KRS (Kerala Roadways)', 'Parveen Travels / Parveen Express', 'SRM Transports', 'KPN Travels & KPN Speed Parcel', 'SRS Travels',
|
||||||
|
'Hindusthan Travels', 'City Travels', 'Essaar Travels', 'No. 1 Air Travels', 'A1 Travels', 'Krish Travels', 'Hebron Transports', 'Horma Travels', 'Vivegam Travels', 'PSS Transport', 'SRT (Renugambal Travels)', 'Thamarai Bus Transports', 'Rathimeena Travels', 'Ganesh Travels', 'John Kennedy Bus Service', 'Arun Travel', 'Saaji Meera Roadways', 'ARC Parcel Service', 'Chakra Travels & Parcel Service', 'MVA Parcel And Bus Service',
|
||||||
|
'Shrinath Travels & Cargo', 'Hans Travels', 'Zingbus', 'Kalpana Travels / City Land Travels', 'Trackon Couriers', 'North India Transways', 'RSRTC Cargo', 'UPSRTC Cargo'
|
||||||
|
];
|
||||||
|
|
||||||
|
const CITIES = [
|
||||||
|
'Chennai', 'Coimbatore', 'Madurai', 'Tiruchirappalli', 'Salem', 'Tuticorin', 'Tirupur', 'Erode', 'Vellore', 'Tirunelveli', 'Thanjavur', 'Dindigul', 'Hosur', 'Nagercoil', 'Karur', 'Namakkal', 'Kanchipuram', 'Cuddalore', 'Thoothukudi',
|
||||||
|
'Bengaluru', 'Mysuru', 'Mangaluru', 'Hubli', 'Belagavi', 'Kalaburagi', 'Davangere', 'Ballari',
|
||||||
|
'Kochi', 'Thiruvananthapuram', 'Kozhikode', 'Kannur', 'Thrissur', 'Kollam',
|
||||||
|
'Hyderabad', 'Warangal', 'Visakhapatnam', 'Vijayawada', 'Tirupati', 'Guntur', 'Rajahmundry', 'Nellore',
|
||||||
|
'Mumbai', 'Pune', 'Nagpur', 'Nashik', 'Aurangabad', 'Kolhapur', 'Solapur',
|
||||||
|
'New Delhi', 'Gurugram', 'Noida', 'Faridabad', 'Chandigarh', 'Amritsar', 'Ludhiana', 'Jalandhar',
|
||||||
|
'Ahmedabad', 'Surat', 'Vadodara', 'Rajkot', 'Bhavnagar', 'Jamnagar',
|
||||||
|
'Jaipur', 'Jodhpur', 'Udaipur', 'Kota', 'Bikaner',
|
||||||
|
'Lucknow', 'Kanpur', 'Varanasi', 'Agra', 'Prayagraj', 'Gorakhpur', 'Bhopal', 'Indore', 'Gwalior', 'Jabalpur', 'Raipur',
|
||||||
|
'Kolkata', 'Siliguri', 'Durgapur', 'Patna', 'Gaya', 'Ranchi', 'Jamshedpur', 'Bhubaneswar', 'Cuttack', 'Guwahati', 'Dibrugarh', 'Agartala', 'Imphal', 'Shillong', 'Aizawl', 'Dimapur',
|
||||||
|
'Dehradun', 'Haridwar', 'Shimla', 'Srinagar', 'Jammu', 'Leh', 'Panaji', 'Puducherry', 'Port Blair'
|
||||||
|
];
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
name: '', phone: '', city: '', businessState: '', businessType: 'retail', status: 'newClient',
|
name: '', email: '', password: '', phone: '', city: '', businessState: '',
|
||||||
frequency: 'Daily', parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
|
businessType: 'retail', status: 'newClient', frequency: 'Daily',
|
||||||
|
parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
|
||||||
logisticsSegment: '', transitFrom: '', transitTo: '', neighbourhood: '',
|
logisticsSegment: '', transitFrom: '', transitTo: '', neighbourhood: '',
|
||||||
surveyAddress: '', surveyLat: '', surveyLng: '', dataConsent: 'basicOnly', notes: ''
|
surveyAddress: '', surveyLat: '', surveyLng: '', dataConsent: 'full', notes: '', pincode: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure a select always has its current value among the options.
|
const formatOption = (s) => typeof s === 'string' ? s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1').trim() : s;
|
||||||
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
||||||
|
|
||||||
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
|
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
|
||||||
@@ -26,6 +50,47 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
const [form, setForm] = useState(EMPTY);
|
const [form, setForm] = useState(EMPTY);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [gettingLocation, setGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
const handleGPS = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert("Geolocation is not supported by your browser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGettingLocation(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(async (pos) => {
|
||||||
|
const lat = pos.coords.latitude;
|
||||||
|
const lng = pos.coords.longitude;
|
||||||
|
setForm((f) => ({ ...f, surveyLat: lat, surveyLng: lng }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.address) {
|
||||||
|
const address = data.address;
|
||||||
|
const city = address.city || address.town || address.village || address.county || '';
|
||||||
|
const state = address.state || '';
|
||||||
|
const pincode = address.postcode || '';
|
||||||
|
const display = data.display_name || '';
|
||||||
|
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
city: city || f.city,
|
||||||
|
businessState: state || f.businessState,
|
||||||
|
pincode: pincode || f.pincode,
|
||||||
|
surveyAddress: display || f.surveyAddress
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Reverse geocoding failed", e);
|
||||||
|
} finally {
|
||||||
|
setGettingLocation(false);
|
||||||
|
}
|
||||||
|
}, (err) => {
|
||||||
|
alert("Unable to retrieve your location");
|
||||||
|
setGettingLocation(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -45,7 +110,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng);
|
const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
first_name: form.name.trim(),
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
city: form.city,
|
city: form.city,
|
||||||
businessState: form.businessState,
|
businessState: form.businessState,
|
||||||
@@ -60,25 +125,22 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
transitFrom: form.transitFrom,
|
transitFrom: form.transitFrom,
|
||||||
transitTo: form.transitTo,
|
transitTo: form.transitTo,
|
||||||
neighbourhood: form.neighbourhood,
|
neighbourhood: form.neighbourhood,
|
||||||
|
pincode: form.pincode,
|
||||||
surveyAddress: form.surveyAddress,
|
surveyAddress: form.surveyAddress,
|
||||||
surveyZone: form.neighbourhood,
|
|
||||||
dataConsent: form.dataConsent,
|
dataConsent: form.dataConsent,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
lastUpdated: new Date().toISOString().slice(0, 10),
|
lastUpdated: new Date().toISOString().slice(0, 10),
|
||||||
...(lat != null ? { surveyLat: lat } : {}),
|
registration_source: 'web',
|
||||||
...(lng != null ? { surveyLng: lng } : {}),
|
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||||
...(lat != null && lng != null ? { surveyGeo: { lat, lon: lng } } : {})
|
...(lat != null ? { survey_lat: lat } : {}),
|
||||||
|
...(lng != null ? { survey_long: lng } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await setPayload(COLLECTIONS.clients, initial.id, payload);
|
await updateClient(initial.id, payload);
|
||||||
} else {
|
} else {
|
||||||
await createPoint(COLLECTIONS.clients, {
|
await createClient({ ...payload, ...(form.password ? { password: form.password } : {}) });
|
||||||
...payload,
|
|
||||||
clientId: `client_${Date.now()}`,
|
|
||||||
surveySubmitted: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -89,36 +151,59 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
|
<>
|
||||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Snackbar
|
||||||
{isEdit ? 'Edit Client' : 'Add Client'}
|
open={!!error}
|
||||||
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
autoHideDuration={6000}
|
||||||
</DialogTitle>
|
onClose={() => setError(null)}
|
||||||
<DialogContent dividers>
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
sx={{ zIndex: 9999 }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
<Typography variant="overline" color="text.secondary">Business</Typography>
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
{isEdit ? 'Edit Client' : 'Add Client'}
|
||||||
|
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography variant="overline" color="text.secondary">Business</Typography>
|
||||||
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email" value={form.email} onChange={set('email')} /></Grid>
|
||||||
|
{!isEdit && (
|
||||||
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Password" type="password" value={form.password} onChange={set('password')} /></Grid>
|
||||||
|
)}
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}>
|
<TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}>
|
||||||
{withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
{withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}>
|
<TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}>
|
||||||
{withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
{withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}>
|
<TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}>
|
||||||
{withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
{withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Parcel Volume" value={form.parcelVolume} onChange={set('parcelVolume')} /></Grid>
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Parcel Volume" value={form.parcelVolume} onChange={set('parcelVolume')} /></Grid>
|
||||||
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Active Contracts" value={form.activeContracts} onChange={set('activeContracts')} /></Grid>
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Active Contracts" value={form.activeContracts} onChange={set('activeContracts')} /></Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Current Provider" value={form.provider} onChange={set('provider')} /></Grid>
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
|
autoHighlight
|
||||||
|
options={PROVIDERS}
|
||||||
|
value={form.provider || null}
|
||||||
|
onChange={(e, newValue) => setForm((f) => ({ ...f, provider: newValue || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Current Provider" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Efficiency" value={form.efficiency} onChange={set('efficiency')} /></Grid>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Efficiency" value={form.efficiency} onChange={set('efficiency')} /></Grid>
|
||||||
<Grid item xs={12}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid>
|
<Grid item xs={12}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -126,13 +211,50 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
<Divider sx={{ my: 1.5 }} />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
<Typography variant="overline" color="text.secondary">Location & Transit</Typography>
|
<Typography variant="overline" color="text.secondary">Location & Transit</Typography>
|
||||||
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="City" value={form.city} onChange={set('city')} /></Grid>
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
|
autoHighlight
|
||||||
|
options={CITIES}
|
||||||
|
value={form.city || null}
|
||||||
|
onChange={(e, newValue) => setForm((f) => ({ ...f, city: newValue || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="City" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Pincode" value={form.pincode} onChange={set('pincode')} /></Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit From" value={form.transitFrom} onChange={set('transitFrom')} /></Grid>
|
<Grid item xs={12} sm={6}>
|
||||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit To" value={form.transitTo} onChange={set('transitTo')} /></Grid>
|
<Autocomplete
|
||||||
|
autoHighlight
|
||||||
|
options={CITIES}
|
||||||
|
value={form.transitFrom || null}
|
||||||
|
onChange={(e, newValue) => setForm((f) => ({ ...f, transitFrom: newValue || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit From" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
|
autoHighlight
|
||||||
|
options={CITIES}
|
||||||
|
value={form.transitTo || null}
|
||||||
|
onChange={(e, newValue) => setForm((f) => ({ ...f, transitTo: newValue || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit To" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Latitude" value={form.surveyLat} onChange={set('surveyLat')} /></Grid>
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Latitude" value={form.surveyLat} onChange={set('surveyLat')} /></Grid>
|
||||||
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Longitude" value={form.surveyLng} onChange={set('surveyLng')} /></Grid>
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Longitude" value={form.surveyLng} onChange={set('surveyLng')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={6} display="flex" alignItems="center">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleGPS}
|
||||||
|
disabled={gettingLocation}
|
||||||
|
startIcon={gettingLocation ? <CircularProgress size={16} /> : <MyLocationIcon />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Get GPS Location
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
|
<Grid item xs={12}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -141,7 +263,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
<Grid container spacing={2} sx={{ mt: 0 }}>
|
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextField select fullWidth size="small" label="Data Consent" value={form.dataConsent} onChange={set('dataConsent')}>
|
<TextField select fullWidth size="small" label="Data Consent" value={form.dataConsent} onChange={set('dataConsent')}>
|
||||||
{withValue(CONSENTS, form.dataConsent).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
{withValue(CONSENTS, form.dataConsent).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid>
|
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid>
|
||||||
@@ -154,5 +276,6 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,62 +33,11 @@ import StatusChip from '@/components/StatusChip';
|
|||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import TabLabelCount from '@/components/TabLabelCount';
|
import TabLabelCount from '@/components/TabLabelCount';
|
||||||
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
|
import { fetchClients, deleteClient } from '@/utils/apiClient';
|
||||||
|
import { toClient } from '@/utils/mappers';
|
||||||
|
import { titleCase } from '@/utils/format';
|
||||||
import ClientFormDialog from './ClientFormDialog';
|
import ClientFormDialog from './ClientFormDialog';
|
||||||
|
|
||||||
const generateLogicalId = (id) => {
|
|
||||||
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
||||||
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map a raw Qdrant point from doormile_clients to a flat client row.
|
|
||||||
function toClient(point) {
|
|
||||||
const p = point.payload || {};
|
|
||||||
|
|
||||||
// Qdrant point.id is usually a UUID.
|
|
||||||
// We generate a clean CLI-XXXXXX format based on it.
|
|
||||||
const logicalId = generateLogicalId(point.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: point.id,
|
|
||||||
logicalId,
|
|
||||||
// Force override the ugly payload client_timestamp string with the clean ID
|
|
||||||
clientId: logicalId,
|
|
||||||
name: p.name || '—',
|
|
||||||
phone: p.phone || '',
|
|
||||||
city: p.city || '',
|
|
||||||
businessState: p.businessState || '',
|
|
||||||
businessType: p.businessType || '',
|
|
||||||
status: p.status || 'unknown',
|
|
||||||
parcelVolume: p.parcelVolume ?? 0,
|
|
||||||
activeContracts: p.activeContracts ?? 0,
|
|
||||||
frequency: p.frequency || '',
|
|
||||||
provider: p.provider || '',
|
|
||||||
efficiency: p.efficiency || '',
|
|
||||||
logisticsSegment: p.logisticsSegment || '',
|
|
||||||
transitFrom: p.transitFrom || '',
|
|
||||||
transitTo: p.transitTo || '',
|
|
||||||
neighbourhood: p.neighbourhood || p.surveyZone || '',
|
|
||||||
surveyAddress: p.surveyAddress || '',
|
|
||||||
surveyLat: p.surveyLat ?? p.surveyGeo?.lat ?? '',
|
|
||||||
surveyLng: p.surveyLng ?? p.surveyGeo?.lon ?? '',
|
|
||||||
dataConsent: p.dataConsent || '',
|
|
||||||
lastUpdated: p.lastUpdated || '',
|
|
||||||
notes: p.notes || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Humanize raw payload tokens like `basicOnly`, `newClient`, `first_mile` → "Basic Only".
|
|
||||||
const humanize = (s) =>
|
|
||||||
String(s || '')
|
|
||||||
.replace(/[_-]+/g, ' ')
|
|
||||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const titleCase = humanize;
|
|
||||||
|
|
||||||
// Map categorical enum values to a semantic palette color.
|
// Map categorical enum values to a semantic palette color.
|
||||||
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
|
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
|
||||||
const efficiencyTone = (v) => {
|
const efficiencyTone = (v) => {
|
||||||
@@ -421,16 +370,28 @@ export default function Tenants() {
|
|||||||
const [toDelete, setToDelete] = useState(null);
|
const [toDelete, setToDelete] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const load = () => {
|
const load = (silent = false) => {
|
||||||
setLoading(true);
|
if (!silent) {
|
||||||
setError(null);
|
setLoading(true);
|
||||||
fetchPoints(COLLECTIONS.clients)
|
setError(null);
|
||||||
|
}
|
||||||
|
fetchClients()
|
||||||
.then((points) => setClients(points.map(toClient)))
|
.then((points) => setClients(points.map(toClient)))
|
||||||
.catch((e) => setError(e.message || 'Failed to load clients'))
|
.catch((e) => {
|
||||||
.finally(() => setLoading(false));
|
if (!silent) setError(e.message || 'Failed to load clients');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
load(true); // Silent poll every 10 seconds
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const stats = useMemo(() => ({
|
const stats = useMemo(() => ({
|
||||||
total: clients.length,
|
total: clients.length,
|
||||||
@@ -473,7 +434,7 @@ export default function Tenants() {
|
|||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deletePoint(COLLECTIONS.clients, toDelete.id);
|
await deleteClient(toDelete.id);
|
||||||
setToDelete(null);
|
setToDelete(null);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
125
src/utils/apiClient.js
Normal file
125
src/utils/apiClient.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||||
|
|
||||||
|
const getHeaders = () => {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJson = (res) => res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
const extractArray = (json) => (Array.isArray(json) ? json : (Array.isArray(json?.data) ? json.data : []));
|
||||||
|
|
||||||
|
export async function fetchClients() {
|
||||||
|
const res = await fetch(`${API_BASE}/crm/clients?limit=1000`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch clients');
|
||||||
|
const json = await res.json();
|
||||||
|
return extractArray(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAdmin(email, password) {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to login');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createClient(payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/crm/clients`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to create client');
|
||||||
|
}
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateClient(id, payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/crm/clients/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to update client');
|
||||||
|
}
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteClient(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/crm/clients/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete client');
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUsers() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/users?limit=1000`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch users');
|
||||||
|
const json = await res.json();
|
||||||
|
return extractArray(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to create user');
|
||||||
|
}
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id, payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to update user');
|
||||||
|
}
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete user');
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboard() {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/dashboard`, { headers: getHeaders() });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch dashboard');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCompetitorBranch(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/admin/competitor-branches/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete survey record');
|
||||||
|
return parseJson(res);
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
// ==============================|| FORMAT HELPERS ||============================== //
|
export const titleCase = (s) =>
|
||||||
|
String(s || '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
.trim();
|
||||||
|
|
||||||
export const inr = (n) =>
|
export const inr = (n) =>
|
||||||
'₹' + Number(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
'₹' + Number(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
|
|||||||
51
src/utils/mappers.js
Normal file
51
src/utils/mappers.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export const generateLogicalId = (id) => {
|
||||||
|
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||||
|
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toClient(raw) {
|
||||||
|
const p = raw.payload ?? raw;
|
||||||
|
const id = p.id ?? raw.id;
|
||||||
|
const logicalId = generateLogicalId(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
logicalId,
|
||||||
|
clientId: logicalId,
|
||||||
|
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),
|
||||||
|
email: p.email || '',
|
||||||
|
phone: p.phone || '',
|
||||||
|
city: p.city || '',
|
||||||
|
businessState: p.businessState || '',
|
||||||
|
businessType: p.businessType || '',
|
||||||
|
status: p.status || 'unknown',
|
||||||
|
parcelVolume: Number(p.parcelVolume) || 0,
|
||||||
|
activeContracts: Number(p.activeContracts) || 0,
|
||||||
|
frequency: p.frequency || '',
|
||||||
|
provider: p.provider || '',
|
||||||
|
efficiency: p.efficiency || '',
|
||||||
|
logisticsSegment: p.logisticsSegment || '',
|
||||||
|
transitFrom: p.transitFrom || '',
|
||||||
|
transitTo: p.transitTo || '',
|
||||||
|
neighbourhood: p.neighbourhood || p.surveyZone || '',
|
||||||
|
surveyAddress: p.surveyAddress || p.address || '',
|
||||||
|
surveyLat: p.survey_lat ?? p.surveyLat ?? '',
|
||||||
|
surveyLng: p.survey_long ?? p.surveyLng ?? '',
|
||||||
|
dataConsent: p.dataConsent || '',
|
||||||
|
lastUpdated: p.lastUpdated || '',
|
||||||
|
pincode: p.pincode || p.postal_code || '',
|
||||||
|
notes: p.notes || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUser(raw) {
|
||||||
|
const p = raw.payload ?? raw;
|
||||||
|
const id = p.id ?? raw.id;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),
|
||||||
|
email: p.email || '',
|
||||||
|
phone: p.phone || '',
|
||||||
|
role: p.role || 'unknown',
|
||||||
|
pin: p.pin || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// ==============================|| QDRANT DATA LAYER ||============================== //
|
|
||||||
// Real connection to the Doormile Qdrant cluster (read + write).
|
|
||||||
//
|
|
||||||
// Requests go through the Vite dev proxy at `/qdrant` (see vite.config.js), which
|
|
||||||
// injects the api-key server-side so it never ships in the browser bundle and CORS
|
|
||||||
// is avoided. For a production build, point VITE_QDRANT_BASE at your own proxy.
|
|
||||||
|
|
||||||
const BASE = import.meta.env.VITE_QDRANT_BASE || '/qdrant';
|
|
||||||
|
|
||||||
export const COLLECTIONS = {
|
|
||||||
clients: 'doormile_clients',
|
|
||||||
teamUsers: 'doormile_auth'
|
|
||||||
};
|
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
...options
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let detail = res.statusText;
|
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
detail = body?.status?.error || body?.status || detail;
|
|
||||||
} catch { /* ignore non-json error bodies */ }
|
|
||||||
throw new Error(`Qdrant ${res.status}: ${detail}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scroll every point of a collection (follows next_page_offset until exhausted).
|
|
||||||
* Returns an array of { id, payload } objects with the raw Qdrant payload.
|
|
||||||
*/
|
|
||||||
export async function fetchPoints(collection, { pageSize = 250, withVector = false } = {}) {
|
|
||||||
const all = [];
|
|
||||||
let offset = null;
|
|
||||||
|
|
||||||
for (let guard = 0; guard < 1000; guard += 1) {
|
|
||||||
const data = await request(`/collections/${collection}/points/scroll`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
limit: pageSize,
|
|
||||||
with_payload: true,
|
|
||||||
with_vector: withVector,
|
|
||||||
...(offset != null ? { offset } : {})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const points = data?.result?.points || [];
|
|
||||||
all.push(...points);
|
|
||||||
|
|
||||||
offset = data?.result?.next_page_offset ?? null;
|
|
||||||
if (offset == null || points.length === 0) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache vector sizes per collection so we don't re-fetch the config on every write.
|
|
||||||
const _vectorSizeCache = {};
|
|
||||||
|
|
||||||
export async function getVectorSize(collection) {
|
|
||||||
if (_vectorSizeCache[collection] != null) return _vectorSizeCache[collection];
|
|
||||||
const data = await request(`/collections/${collection}`);
|
|
||||||
const vectors = data?.result?.config?.params?.vectors;
|
|
||||||
// Single unnamed vector → { size, distance }. Default to 1 if absent.
|
|
||||||
const size = typeof vectors?.size === 'number' ? vectors.size : 1;
|
|
||||||
_vectorSizeCache[collection] = size;
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the payload of an existing point (merges the given keys; vectors untouched).
|
|
||||||
*/
|
|
||||||
export async function setPayload(collection, id, payload) {
|
|
||||||
return request(`/collections/${collection}/points/payload?wait=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ payload, points: [id] })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a brand-new point. The collection requires a vector of a fixed size, but
|
|
||||||
* this CRM doesn't do semantic search, so we store a zero-vector of the right length.
|
|
||||||
* Returns the generated point id.
|
|
||||||
*/
|
|
||||||
export async function createPoint(collection, payload) {
|
|
||||||
const size = await getVectorSize(collection);
|
|
||||||
const id = (crypto?.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
const vector = new Array(size).fill(0);
|
|
||||||
|
|
||||||
await request(`/collections/${collection}/points?wait=true`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ points: [{ id, vector, payload }] })
|
|
||||||
});
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a point by id.
|
|
||||||
*/
|
|
||||||
export async function deletePoint(collection, id) {
|
|
||||||
return request(`/collections/${collection}/points/delete?wait=true`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ points: [id] })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user