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

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

BIN
Enquiry.xlsx Normal file

Binary file not shown.

1
scratch.txt Normal file
View File

@@ -0,0 +1 @@
none

View File

@@ -31,6 +31,12 @@ export default function App() {
<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="/settings" element={load(() => import('@/pages/Settings'))} />

View File

@@ -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 }) {
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 />;
}

View File

@@ -42,8 +42,8 @@ export default function Logo({ onDark = false, compact = false, height = 26, sx
height,
width: 'auto',
display: 'block',
// The asset is white; on light surfaces recolour it to near-black so it stays visible.
filter: onDark ? 'none' : 'brightness(0) saturate(100%)'
// The asset is white; on light surfaces recolour it to Doormile Red (#C01227)
filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
}}
/>
</Box>

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AppBar,
@@ -26,7 +26,8 @@ import LogoutIcon from '@mui/icons-material/Logout';
import Logo from '@/components/Logo';
import UserAvatar from '@/components/UserAvatar';
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
import { fetchClients, fetchUsers } from '@/utils/apiClient';
import { toClient } from '@/utils/mappers';
const RED = '#C01227';
@@ -35,24 +36,49 @@ export default function Header({ onToggle }) {
const [account, setAccount] = useState(null);
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 [clients, setClients] = useState([]);
const [loadedClients, setLoadedClients] = useState(false);
const [loadingClients, setLoadingClients] = useState(false);
const [openResults, setOpenResults] = useState(false);
// Fetch users to get the real name if the login payload didn't have it
useEffect(() => {
fetchUsers().then((users) => {
if (storedUserObj.email) {
// Strictly use email to match, as stored ID might belong to the doormile_auth table instead of appusers table
const matchingUser = users.find(u => u.email === storedUserObj.email);
if (matchingUser && matchingUser.first_name) {
setActiveUserName(matchingUser.first_name);
// Also update localStorage so it's fresh
const updatedUser = { ...storedUserObj, name: matchingUser.first_name };
localStorage.setItem('user', JSON.stringify(updatedUser));
}
}
}).catch(console.error);
}, []);
const ensureClients = () => {
if (loadedClients || loadingClients) return;
setLoadingClients(true);
fetchPoints(COLLECTIONS.clients)
.then((points) => setClients(points.map((p) => ({
id: p.id,
name: p.payload?.name || '—',
city: p.payload?.city || '',
businessType: p.payload?.businessType || '',
phone: p.payload?.phone || ''
}))))
fetchClients()
.then((points) => setClients(points.map(toClient)))
.catch(() => {})
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
};
@@ -84,24 +110,22 @@ export default function Header({ onToggle }) {
<AppBar
position="fixed"
elevation={0}
sx={{ bgcolor: RED, color: '#fff', zIndex: (t) => t.zIndex.drawer + 1, boxShadow: '0 1px 0 rgba(0,0,0,0.06)' }}
sx={{ bgcolor: '#fff', color: 'grey.800', zIndex: (t) => t.zIndex.drawer + 1, borderBottom: 1, borderColor: 'divider' }}
>
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
<MenuIcon />
</IconButton>
{/* Brand wordmark — left side */}
<Box
onClick={() => navigate('/dashboard')}
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<Logo onDark height={22} />
<Logo height={22} />
</Box>
<Box sx={{ flexGrow: 1 }} />
{/* Search — live client lookup */}
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
<Box
@@ -111,23 +135,23 @@ export default function Header({ onToggle }) {
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: alpha('#fff', 0.16),
bgcolor: 'grey.100',
borderRadius: 2,
px: 1.5,
py: 0.5,
width: { sm: 240, md: 320 },
'&:hover': { bgcolor: alpha('#fff', 0.22) },
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
'&:hover': { bgcolor: 'grey.200' },
'&:focus-within': { bgcolor: 'grey.200' }
}}
>
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'grey.500' }} />
<InputBase
value={search}
onChange={onSearchChange}
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
placeholder="Search clients…"
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: 'grey.500' } }}
inputProps={{ 'aria-label': 'search' }}
/>
</Box>
@@ -171,20 +195,19 @@ export default function Header({ onToggle }) {
<Box
onClick={(e) => setAccount(e.currentTarget)}
sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 0.5, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: alpha('#fff', 0.14) } }}
sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 0.5, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: 'grey.100' } }}
>
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>A</Avatar>
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
<Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600 }}>
Admin
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
{activeUserName}
</Typography>
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
Operations Admin
<Typography variant="caption" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
{displayRole}
</Typography>
</Box>
</Box>
{/* Account dropdown */}
<Menu
anchorEl={account}
open={Boolean(account)}
@@ -195,10 +218,10 @@ export default function Header({ onToggle }) {
>
<Box sx={{ px: 2, py: 1.5 }}>
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 38, height: 38, bgcolor: RED, color: '#fff', fontWeight: 700 }}>A</Avatar>
<Avatar sx={{ width: 38, height: 38, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
<Box sx={{ lineHeight: 1.2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Admin</Typography>
<Typography variant="caption" color="text.secondary">Operations Admin</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{activeUserName}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'capitalize' }}>{displayRole}</Typography>
</Box>
</Stack>
</Box>
@@ -208,7 +231,7 @@ export default function Header({ onToggle }) {
Settings
</MenuItem>
<Divider />
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}>
<MenuItem onClick={() => { setAccount(null); localStorage.removeItem('auth_token'); navigate('/login'); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout
</MenuItem>

View File

@@ -10,7 +10,8 @@ import {
Typography,
Collapse,
Tooltip,
Toolbar
Toolbar,
alpha
} from '@mui/material';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
@@ -37,13 +38,15 @@ function NavLeaf({ item, open, active, depth = 0, onClick }) {
px: open ? 1.5 : 0,
justifyContent: open ? 'flex-start' : 'center',
borderRadius: 2,
color: 'rgba(255,255,255,0.78)',
'& .MuiListItemIcon-root': { color: 'inherit' },
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' },
color: active ? RED : 'grey.700',
'& .MuiListItemIcon-root': { color: active ? RED : 'grey.500' },
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } },
'&.Mui-selected': {
bgcolor: 'rgba(255,255,255,0.18)',
color: '#fff',
'&:hover': { bgcolor: 'rgba(255,255,255,0.22)' }
bgcolor: alpha(RED, 0.08),
color: RED,
'& .MuiListItemIcon-root': { color: RED },
borderLeft: open ? `4px solid ${RED}` : 'none',
'&:hover': { bgcolor: alpha(RED, 0.12) }
}
}}
>
@@ -80,9 +83,9 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
};
const content = (
<Box sx={{ bgcolor: RED, height: '100%', color: '#fff', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ bgcolor: '#fff', height: '100%', color: 'grey.800', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
<Logo onDark compact={!expanded} />
<Logo compact={!expanded} />
</Toolbar>
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
{navItems.map((grp) => (
@@ -90,7 +93,7 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
{expanded && (
<Typography
variant="overline"
sx={{ px: 2.5, color: 'rgba(255,255,255,0.55)', fontSize: '0.6875rem', letterSpacing: '0.08em' }}
sx={{ px: 2.5, color: '#A06060', fontSize: '0.6875rem', letterSpacing: '0.08em', fontWeight: 700 }}
>
{grp.group}
</Typography>
@@ -115,12 +118,13 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
px: expanded ? 1.5 : 0,
justifyContent: expanded ? 'flex-start' : 'center',
borderRadius: 2,
color: childActive ? '#fff' : 'rgba(255,255,255,0.78)',
bgcolor: childActive && !opened ? 'rgba(255,255,255,0.12)' : 'transparent',
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' }
color: childActive ? RED : 'grey.700',
bgcolor: childActive && !opened ? alpha(RED, 0.08) : 'transparent',
'& .MuiListItemIcon-root': { color: childActive ? RED : 'grey.500' },
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } }
}}
>
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center', color: 'inherit' }}>
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center' }}>
<Icon fontSize="small" />
</ListItemIcon>
{expanded && (
@@ -155,8 +159,8 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
))}
</Box>
{expanded && (
<Box sx={{ p: 2, borderTop: '1px solid rgba(255,255,255,0.12)' }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.55)' }}>
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Doormile CRM v1.0
</Typography>
</Box>

View File

@@ -2,6 +2,7 @@ import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import AttachMoneyOutlinedIcon from '@mui/icons-material/AttachMoneyOutlined';
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
@@ -11,6 +12,9 @@ const navItems = [
items: [
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
{ 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 }
]
},

View File

@@ -19,42 +19,14 @@ import StatusChip from '@/components/StatusChip';
import DonutChart from '@/components/charts/DonutChart';
import UserAvatar from '@/components/UserAvatar';
import EmptyState from '@/components/EmptyState';
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
import bgImage from '@/assets/premium_logistics_bg.png';
const titleCase = (s) =>
String(s || '').replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
import { fetchClients, fetchUsers } from '@/utils/apiClient';
import { toClient, toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
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 }) {
return (
<Card sx={{
@@ -92,10 +64,10 @@ export default function Dashboard() {
const load = () => {
setLoading(true);
setError(null);
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)])
Promise.all([fetchClients(), fetchUsers()])
.then(([cs, us]) => {
setClients(cs.map(toClient));
setTeam(us.map(toUser));
setClients((cs || []).map(toClient));
setTeam((us || []).map(toUser));
})
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
.finally(() => setLoading(false));

View File

@@ -24,7 +24,7 @@ const TIMEZONES = ['Asia/Kolkata (IST)', 'Asia/Dubai (GST)', 'UTC', 'America/New
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
const INITIAL_GENERAL = {
orgName: 'Doormile Logistics Pvt. Ltd.',
orgName: 'Doormile Technologies',
supportEmail: 'support@doormile.in',
contact: '+91 63749 46729',
timezone: TIMEZONES[0],

View File

@@ -20,11 +20,31 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
import Logo from '@/components/Logo';
import bgImage from '../../assets/mid-mile-approach.jpg';
import { loginAdmin } from '@/utils/apiClient';
export default function Login() {
const navigate = useNavigate();
const [show, setShow] = useState(false);
const [auth, setAuth] = 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 (
<Box
@@ -70,12 +90,13 @@ export default function Login() {
<Stack spacing={2.5}>
<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
fullWidth
placeholder="Enter your auth name"
placeholder="Enter your email address"
value={auth}
onChange={(e) => setAuth(e.target.value)}
onKeyDown={handleKeyDown}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(255,255,255,0.6)',
@@ -95,6 +116,7 @@ export default function Login() {
placeholder="Enter your password"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
onKeyDown={handleKeyDown}
sx={{
'& .MuiOutlinedInput-root': {
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>} />
<Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link>
</Stack>
{error && <Typography variant="body2" color="error" textAlign="center" sx={{ fontWeight: 600 }}>{error}</Typography>}
<Button
fullWidth
size="large"
variant="contained"
onClick={() => { localStorage.setItem('auth_token', 'demo-session'); navigate('/dashboard'); }}
onClick={handleLogin}
sx={{
mt: 2,
py: 1.5,
@@ -134,9 +159,8 @@ export default function Login() {
boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)',
'&:hover': {
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
@@ -146,7 +170,7 @@ export default function Login() {
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}>
<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>
</Box>
</Box>

View 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>
);
}

View 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
View 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>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -24,24 +24,11 @@ import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar';
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';
// 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.
const ROLE_META = {
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
@@ -111,7 +98,7 @@ export default function TeamUsers() {
const load = () => {
setLoading(true);
setError(null);
fetchPoints(COLLECTIONS.teamUsers)
fetchUsers()
.then((points) => setUsers(points.map(toUser)))
.catch((e) => setError(e.message || 'Failed to load team users'))
.finally(() => setLoading(false));
@@ -151,7 +138,7 @@ export default function TeamUsers() {
const confirmDelete = async () => {
setDeleting(true);
try {
await deletePoint(COLLECTIONS.teamUsers, toDelete.id);
await deleteUser(toDelete.id);
setToDelete(null);
load();
} catch (e) {
@@ -203,7 +190,7 @@ export default function TeamUsers() {
<Typography variant="body2" color="text.secondary">
{filtered.length} {filtered.length === 1 ? 'user' : 'users'}
</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>
@@ -291,7 +278,7 @@ export default function TeamUsers() {
<DialogTitle>Delete user?</DialogTitle>
<DialogContent>
<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>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>

View File

@@ -4,11 +4,11 @@ import {
MenuItem, Alert, CircularProgress, IconButton
} from '@mui/material';
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 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);
@@ -30,22 +30,24 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
const handleSave = async () => {
if (!form.name.trim()) { setError('Name is required.'); return; }
if (!form.email.trim()) { setError('Email is required.'); return; }
if (!isEdit && !form.password.trim()) { setError('Password is required.'); return; }
setSaving(true);
setError(null);
const payload = {
name: form.name.trim(),
first_name: form.name.trim(),
email: form.email.trim(),
phone: form.phone,
role: form.role,
...(form.pin ? { pin: String(form.pin) } : {})
...(form.password ? { password: form.password } : {}),
...(form.pin ? { pin: String(form.pin) } : {}),
};
try {
if (isEdit) {
await setPayload(COLLECTIONS.teamUsers, initial.id, payload);
await updateUser(initial.id, payload);
} else {
await createPoint(COLLECTIONS.teamUsers, payload);
await createUser(payload);
}
onSaved();
} catch (e) {
@@ -73,6 +75,14 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
</TextField>
</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>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>

View File

@@ -1,24 +1,48 @@
import { useState, useEffect } from 'react';
import {
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';
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 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 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 = {
name: '', phone: '', city: '', businessState: '', businessType: 'retail', status: 'newClient',
frequency: 'Daily', parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
name: '', email: '', password: '', phone: '', city: '', businessState: '',
businessType: 'retail', status: 'newClient', frequency: 'Daily',
parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
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);
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 [saving, setSaving] = useState(false);
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(() => {
if (open) {
@@ -45,7 +110,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng);
const payload = {
name: form.name.trim(),
first_name: form.name.trim(),
phone: form.phone,
city: form.city,
businessState: form.businessState,
@@ -60,25 +125,22 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
transitFrom: form.transitFrom,
transitTo: form.transitTo,
neighbourhood: form.neighbourhood,
pincode: form.pincode,
surveyAddress: form.surveyAddress,
surveyZone: form.neighbourhood,
dataConsent: form.dataConsent,
notes: form.notes,
lastUpdated: new Date().toISOString().slice(0, 10),
...(lat != null ? { surveyLat: lat } : {}),
...(lng != null ? { surveyLng: lng } : {}),
...(lat != null && lng != null ? { surveyGeo: { lat, lon: lng } } : {})
registration_source: 'web',
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(lat != null ? { survey_lat: lat } : {}),
...(lng != null ? { survey_long: lng } : {}),
};
try {
if (isEdit) {
await setPayload(COLLECTIONS.clients, initial.id, payload);
await updateClient(initial.id, payload);
} else {
await createPoint(COLLECTIONS.clients, {
...payload,
clientId: `client_${Date.now()}`,
surveySubmitted: false
});
await createClient({ ...payload, ...(form.password ? { password: form.password } : {}) });
}
onSaved();
} catch (e) {
@@ -89,36 +151,59 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
};
return (
<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>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<>
<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>
<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 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="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}>
<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>
</Grid>
<Grid item xs={12} sm={6}>
<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>
</Grid>
<Grid item xs={12} sm={6}>
<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>
</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={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}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid>
</Grid>
@@ -126,13 +211,50 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
<Divider sx={{ my: 1.5 }} />
<Typography variant="overline" color="text.secondary">Location & Transit</Typography>
<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="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="Transit From" value={form.transitFrom} onChange={set('transitFrom')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit To" value={form.transitTo} onChange={set('transitTo')} /></Grid>
<Grid item xs={12} sm={6}>
<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="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>
@@ -141,7 +263,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
<Grid container spacing={2} sx={{ mt: 0 }}>
<Grid item xs={12} sm={6}>
<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>
</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>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -33,62 +33,11 @@ import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar';
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';
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.
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
const efficiencyTone = (v) => {
@@ -421,16 +370,28 @@ export default function Tenants() {
const [toDelete, setToDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const load = () => {
setLoading(true);
setError(null);
fetchPoints(COLLECTIONS.clients)
const load = (silent = false) => {
if (!silent) {
setLoading(true);
setError(null);
}
fetchClients()
.then((points) => setClients(points.map(toClient)))
.catch((e) => setError(e.message || 'Failed to load clients'))
.finally(() => setLoading(false));
.catch((e) => {
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(() => ({
total: clients.length,
@@ -473,7 +434,7 @@ export default function Tenants() {
const confirmDelete = async () => {
setDeleting(true);
try {
await deletePoint(COLLECTIONS.clients, toDelete.id);
await deleteClient(toDelete.id);
setToDelete(null);
load();
} catch (e) {

125
src/utils/apiClient.js Normal file
View 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);
}

View File

@@ -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) =>
'₹' + Number(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });

51
src/utils/mappers.js Normal file
View 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 || ''
};
}

View File

@@ -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] })
});
}