Compare commits

7 Commits

24 changed files with 2650 additions and 383 deletions

BIN
Enquiry.xlsx Normal file

Binary file not shown.

View File

@@ -1,50 +1,54 @@
# Doormile Console — Admin # Doormile CRM
A modern, corporate logistics console for **Doormile**, a last-mile / courier delivery operation. Built with **React 18 + Vite + Material-UI v5**, themed around the Doormile brand red `#C01227`. A modern, corporate customer relationship management system for **Doormile**, a last-mile logistics and courier delivery operation. Built with **React 18 + Vite + Material-UI v5**, themed around the Doormile brand red `#C01227`.
## Quick start ## Quick Start
```bash ```bash
# Install dependencies
npm install npm install
npm run dev # http://localhost:3000
npm run build # production build → dist/ # Run the development server (default port: 5173 or similar)
npm run preview # preview the production build npm run dev
# Build the project for production
npm run build
# Preview the production build locally
npm run preview
``` ```
## What's inside ## Application Structure & Pages
A clean, data-dense but breathable corporate shell — fixed **red top header** (search, notifications, messages, profile) + **collapsible red sidebar** (260px ↔ 78px icon rail) + light `#FAFAFB` content area — wrapping **27 screens**: Doormile CRM features a clean, responsive layout consisting of a **collapsible sidebar** and a **header panel**, wrapping the following key modules:
| Area | Screens | | Route Path | Page Component | Description |
| --- | --- | | --- | --- | --- |
| Overview | Dashboard | | `/dashboard` | `Dashboard.jsx` | Overview of registered clients, active contracts, total parcel volume, status donuts, and quick-view charts. |
| Orders | Orders list · Order Details (tracking + timeline) · Create Order · Create Multiple Orders · Assign Orders | | `/tenants` | `Tenants.jsx` | Management view for clients, including expandable details (parcel volume, logistics segments, transit routes, and coordinates map link). |
| Deliveries | Deliveries (expandable products, status tabs) | | `/survey` | `Survey.jsx` | Provider survey records mapping competitors, branch locations, and rate-per-kg answers in an aligned grid layout. |
| Network | Tenants · Create Client · Customers · Create Customer · Pricing · Riders · Create Rider · Edit Rider | | `/pricing` | `Pricing.jsx` | Pricing matrix slabs and logistics carrier rates management with search and active-provider selectors. |
| Reports | Order Summary · Order Details · Riders Summary · Riders Logs (live map) | | `/bookings` | `Bookings.jsx` | Courier bookings creation and management dialogs, along with pricing checks. |
| Finance | Invoices · Invoice Preview (printable A4) · Requests | | `/app-users` | `TeamUsers.jsx` | Team member list, user roles configuration, and initial permission setup. |
| Account | User Profile | | `/settings` | `Settings.jsx` | Preferences dashboard covering organization profiles, system notification switches, and security/password updates. |
| Auth & states | Login · 404 · 500 · Under Construction · Coming Soon |
## Design system ## Design System
- **Brand red** `#C01227` (`lighter #F8E0E3darker #7E0B17`), white-on-red header/sidebar. - **Brand red** `#C01227` (`lighter #F8E0E3` and `darker #7E0B17`) representing the primary action theme color.
- **Status palette** — success `#00A854`, warning `#FFBF00`, info `#00A2AE`, error `#F04134`. - **Status palette** — success `#00A854`, warning `#FFBF00`, info `#00A2AE`, error `#F04134`.
- **Public Sans** type scale, 610px radii, soft `0 1px 4px` card shadows, and a signature **red glow** on primary CTAs. - **Public Sans** typography scale, clean border-radii (`8px` to `16px`), soft shadows, and subtle responsive layouts (flex-based grid column alignment).
- All tuning lives in `src/theme/` (`palette.js`, `typography.js`, `shadows.js`, `componentsOverride.js`). - Theme configuration resides in `src/theme/`.
## Project structure ## Project Folders
``` ```
src/ src/
theme/ Doormile red theme + MUI component overrides components/ Reusable UI widgets (PageHeader, StatCard, StatusChip, EmptyState)
layout/ MainLayout (red header + collapsible sidebar) · MinimalLayout layout/ MainLayout (header + collapsible sidebar) & MinimalLayout
menu/ sidebar nav config menu/ Sidebar navigation mappings (`navItems.jsx`)
components/ PageHeader, StatCard, StatusChip, MainCard, EmptyState, pages/ CRM modules and page components (dashboard, tenants, survey, pricing, bookings, team, settings)
UserAvatar, MapPlaceholder, Logo, charts/ (Area, Donut) theme/ Doormile custom theme configurations and component style overrides
data/mock.js static demo data powering every screen utils/ API fetching wrapper client
pages/ all 27 screens (lazy-loaded) App.jsx App router and route definitions
App.jsx router · main.jsx app entry main.jsx Application entry point
``` ```
Charts and maps are dependency-free SVG placeholders (`components/charts`, `MapPlaceholder`) — swap in Recharts / Leaflet / Google Maps when wiring real data. All data is mocked in `src/data/mock.js`; there is no backend.

1
scratch.txt Normal file
View File

@@ -0,0 +1 @@
none

View File

@@ -30,8 +30,14 @@ export default function App() {
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} /> <Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} /> <Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
<Route path="/survey" element={load(() => import('@/pages/survey/Survey.jsx'))} />
<Route path="/pricing" element={load(() => import('@/pages/pricing/Pricing.jsx'))} />
<Route path="/bookings" element={load(() => import('@/pages/bookings/Bookings'))} />
<Route path="/team-users" element={load(() => import('@/pages/team/TeamUsers'))} /> <Route path="/app-users" element={load(() => import('@/pages/team/TeamUsers'))} />
<Route path="/settings" element={load(() => import('@/pages/Settings'))} /> <Route path="/settings" element={load(() => import('@/pages/Settings'))} />
</Route> </Route>

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 }) { export default function AuthGuard({ children }) {
const token = localStorage.getItem('auth_token'); const navigate = useNavigate();
const loggedIn = localStorage.getItem('logged_in');
if (!token) { const timerRef = useRef(null);
useEffect(() => {
if (!loggedIn) return;
const logout = () => {
localStorage.removeItem('logged_in');
localStorage.removeItem('auth_token');
localStorage.removeItem('user_data');
navigate('/login', { replace: true });
};
const resetTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(logout, INACTIVITY_LIMIT_MS);
};
const events = ['mousemove', 'keydown', 'scroll', 'click'];
events.forEach(event => window.addEventListener(event, resetTimer));
resetTimer(); // Start the timer initially
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
events.forEach(event => window.removeEventListener(event, resetTimer));
};
}, [loggedIn, navigate]);
if (!loggedIn) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,42 +20,14 @@ import StatusChip from '@/components/StatusChip';
import DonutChart from '@/components/charts/DonutChart'; import DonutChart from '@/components/charts/DonutChart';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant'; import { fetchClients, fetchUsers } from '@/utils/apiClient';
import bgImage from '@/assets/premium_logistics_bg.png'; import { toClient, toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
const titleCase = (s) =>
String(s || '').replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' }; const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C'; const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C']; const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C'];
const generateLogicalId = (id) => {
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
};
function toClient(point) {
const p = point.payload || {};
return {
id: point.id,
logicalId: generateLogicalId(point.id),
name: p.name || '—',
businessType: p.businessType || '',
city: p.city || '',
businessState: p.businessState || '',
status: p.status || 'unknown',
parcelVolume: Number(p.parcelVolume) || 0,
activeContracts: Number(p.activeContracts) || 0,
lastUpdated: p.lastUpdated || ''
};
}
function toUser(point) {
const p = point.payload || {};
return { id: point.id, name: p.name || '—', email: p.email || '', role: p.role || 'unknown' };
}
// Card with a tinted icon header band.
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) { function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
return ( return (
<Card sx={{ <Card sx={{
@@ -69,7 +41,7 @@ function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false
<Stack <Stack
direction="row" spacing={1.5} alignItems="center" direction="row" spacing={1.5} alignItems="center"
sx={{ sx={{
px: { xs: 2, sm: 3 }, py: 2, borderBottom: 1, borderColor: 'divider', px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)` background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)`
}} }}
> >
@@ -95,10 +67,10 @@ export default function Dashboard() {
const load = () => { const load = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)]) Promise.all([fetchClients(), fetchUsers()])
.then(([cs, us]) => { .then(([cs, us]) => {
setClients(cs.map(toClient)); setClients((cs || []).map(toClient));
setTeam(us.map(toUser)); setTeam((us || []).map(toUser));
}) })
.catch((e) => setError(e.message || 'Failed to load dashboard data')) .catch((e) => setError(e.message || 'Failed to load dashboard data'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -152,9 +124,7 @@ export default function Dashboard() {
{error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
<Grid container spacing={2.5}>
<Grid container spacing={3}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} color="primary" caption="All registered" /></Grid> <Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} color="primary" caption="All registered" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid> <Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid> <Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid>
@@ -228,7 +198,7 @@ export default function Dashboard() {
{byType.length === 0 ? ( {byType.length === 0 ? (
<EmptyState title="No data" /> <EmptyState title="No data" />
) : ( ) : (
<Stack spacing={2.25}> <Stack spacing={2}>
{byType.map(([type, count], i) => ( {byType.map(([type, count], i) => (
<Box key={type}> <Box key={type}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.75 }}> <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.75 }}>
@@ -259,7 +229,7 @@ export default function Dashboard() {
) : ( ) : (
<Stack divider={<Divider />} spacing={0}> <Stack divider={<Divider />} spacing={0}>
{team.slice(0, 6).map((u) => ( {team.slice(0, 6).map((u) => (
<Stack key={u.id} direction="row" spacing={1.5} alignItems="center" sx={{ py: 1.25 }}> <Stack key={u.id} direction="row" spacing={1.5} alignItems="center" sx={{ py: 1.5 }}>
<UserAvatar name={u.name} size={36} /> <UserAvatar name={u.name} size={36} />
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography>

View File

@@ -24,7 +24,7 @@ const TIMEZONES = ['Asia/Kolkata (IST)', 'Asia/Dubai (GST)', 'UTC', 'America/New
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)']; const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
const INITIAL_GENERAL = { const INITIAL_GENERAL = {
orgName: 'Doormile Logistics Pvt. Ltd.', orgName: 'Doormile Technologies',
supportEmail: 'support@doormile.in', supportEmail: 'support@doormile.in',
contact: '+91 63749 46729', contact: '+91 63749 46729',
timezone: TIMEZONES[0], timezone: TIMEZONES[0],
@@ -71,7 +71,7 @@ function Section({ icon: Icon, title, subtitle, color = 'primary', danger = fals
<Stack <Stack
direction="row" spacing={1.75} alignItems="center" direction="row" spacing={1.75} alignItems="center"
sx={{ sx={{
px: { xs: 2, sm: 3 }, py: 2.25, borderBottom: 1, borderColor: 'divider', px: { xs: 2, sm: 3 }, py: 2, borderBottom: 1, borderColor: 'divider',
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, ${theme.palette.background.paper} 72%)` background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, ${theme.palette.background.paper} 72%)`
}} }}
> >
@@ -170,7 +170,7 @@ export default function Settings() {
{/* Sidebar */} {/* Sidebar */}
<Grid item xs={12} md={3}> <Grid item xs={12} md={3}>
<Stack spacing={2.5} sx={{ position: { md: 'sticky' }, top: { md: 88 } }}> <Stack spacing={2.5} sx={{ position: { md: 'sticky' }, top: { md: 88 } }}>
<Card sx={{ p: 1.5 }}> <Card sx={{ p: 2 }}>
<Typography variant="overline" sx={{ px: 1, color: 'text.secondary', fontWeight: 700, letterSpacing: 0.6 }}>Preferences</Typography> <Typography variant="overline" sx={{ px: 1, color: 'text.secondary', fontWeight: 700, letterSpacing: 0.6 }}>Preferences</Typography>
<Stack spacing={0.25} sx={{ mt: 0.5 }}> <Stack spacing={0.25} sx={{ mt: 0.5 }}>
{NAV.map((item, i) => { {NAV.map((item, i) => {
@@ -182,7 +182,7 @@ export default function Settings() {
direction="row" spacing={1.5} alignItems="center" direction="row" spacing={1.5} alignItems="center"
onClick={() => setTab(i)} onClick={() => setTab(i)}
sx={{ sx={{
px: 1.5, py: 1.25, borderRadius: 2, cursor: 'pointer', position: 'relative', px: 1.5, py: 1.5, borderRadius: 2, cursor: 'pointer', position: 'relative',
bgcolor: active ? 'primary.lighter' : 'transparent', bgcolor: active ? 'primary.lighter' : 'transparent',
transition: 'background-color .15s', transition: 'background-color .15s',
'&:hover': { bgcolor: active ? 'primary.lighter' : 'grey.50' }, '&:hover': { bgcolor: active ? 'primary.lighter' : 'grey.50' },
@@ -203,7 +203,7 @@ export default function Settings() {
</Stack> </Stack>
</Card> </Card>
<Card sx={{ p: 2.5, bgcolor: 'primary.lighter', borderColor: 'primary.100' }}> <Card sx={{ p: 2, bgcolor: 'primary.lighter', borderColor: 'primary.100' }}>
<Stack spacing={1.25}> <Stack spacing={1.25}>
<Box sx={{ width: 38, height: 38, borderRadius: 2, bgcolor: 'primary.main', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Box sx={{ width: 38, height: 38, borderRadius: 2, bgcolor: 'primary.main', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<HelpOutlineRoundedIcon fontSize="small" /> <HelpOutlineRoundedIcon fontSize="small" />

View File

@@ -20,11 +20,31 @@ import VisibilityOff from '@mui/icons-material/VisibilityOff';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import bgImage from '../../assets/mid-mile-approach.jpg'; import bgImage from '../../assets/mid-mile-approach.jpg';
import { loginAdmin } from '@/utils/apiClient';
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [auth, setAuth] = useState(''); const [auth, setAuth] = useState('');
const [pwd, setPwd] = useState(''); const [pwd, setPwd] = useState('');
const [error, setError] = useState('');
const handleLogin = async () => {
try {
setError('');
const data = await loginAdmin(auth, pwd);
localStorage.setItem('logged_in', 'true');
localStorage.setItem('auth_token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
navigate('/dashboard');
} catch (err) {
setError(err.message || 'Invalid email or password');
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') handleLogin();
};
return ( return (
<Box <Box
@@ -70,12 +90,13 @@ export default function Login() {
<Stack spacing={2.5}> <Stack spacing={2.5}>
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Auth Name</Typography> <Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Email</Typography>
<TextField <TextField
fullWidth fullWidth
placeholder="Enter your auth name" placeholder="Enter your email address"
value={auth} value={auth}
onChange={(e) => setAuth(e.target.value)} onChange={(e) => setAuth(e.target.value)}
onKeyDown={handleKeyDown}
sx={{ sx={{
'& .MuiOutlinedInput-root': { '& .MuiOutlinedInput-root': {
bgcolor: 'rgba(255,255,255,0.6)', bgcolor: 'rgba(255,255,255,0.6)',
@@ -95,6 +116,7 @@ export default function Login() {
placeholder="Enter your password" placeholder="Enter your password"
value={pwd} value={pwd}
onChange={(e) => setPwd(e.target.value)} onChange={(e) => setPwd(e.target.value)}
onKeyDown={handleKeyDown}
sx={{ sx={{
'& .MuiOutlinedInput-root': { '& .MuiOutlinedInput-root': {
bgcolor: 'rgba(255,255,255,0.6)', bgcolor: 'rgba(255,255,255,0.6)',
@@ -119,11 +141,14 @@ export default function Login() {
<FormControlLabel control={<Checkbox defaultChecked size="small" sx={{ color: '#cbd5e1', '&.Mui-checked': { color: 'primary.main' } }} />} label={<Typography variant="body2" sx={{ color: '#475569', fontWeight: 500 }}>Remember me</Typography>} /> <FormControlLabel control={<Checkbox defaultChecked size="small" sx={{ color: '#cbd5e1', '&.Mui-checked': { color: 'primary.main' } }} />} label={<Typography variant="body2" sx={{ color: '#475569', fontWeight: 500 }}>Remember me</Typography>} />
<Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link> <Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link>
</Stack> </Stack>
{error && <Typography variant="body2" color="error" textAlign="center" sx={{ fontWeight: 600 }}>{error}</Typography>}
<Button <Button
fullWidth fullWidth
size="large" size="large"
variant="contained" variant="contained"
onClick={() => { localStorage.setItem('auth_token', 'demo-session'); navigate('/dashboard'); }} onClick={handleLogin}
sx={{ sx={{
mt: 2, mt: 2,
py: 1.5, py: 1.5,
@@ -134,9 +159,8 @@ export default function Login() {
boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)', boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)',
'&:hover': { '&:hover': {
boxShadow: '0 12px 20px rgba(192, 18, 39, 0.35)', boxShadow: '0 12px 20px rgba(192, 18, 39, 0.35)',
transform: 'translateY(-1px)'
}, },
transition: 'all 0.2s ease' transition: 'box-shadow 0.2s ease'
}} }}
> >
Sign In Sign In
@@ -146,7 +170,7 @@ export default function Login() {
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}> <Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}> <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}>
© {new Date().getFullYear()} Doormile Logistics Pvt. Ltd. All rights reserved. © {new Date().getFullYear()} Doormile Technologies. All rights reserved.
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@@ -0,0 +1,589 @@
import { useState, useEffect } from 'react';
import {
Card, Box, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Typography, TextField, Button, Dialog, DialogTitle, DialogContent, DialogActions,
Grid, IconButton, CircularProgress, Chip, MenuItem, InputAdornment, TablePagination, 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 LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import PhoneIphoneOutlinedIcon from '@mui/icons-material/PhoneIphoneOutlined';
import DesktopWindowsOutlinedIcon from '@mui/icons-material/DesktopWindowsOutlined';
import PendingActionsOutlinedIcon from '@mui/icons-material/PendingActionsOutlined';
import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import EmptyState from '@/components/EmptyState';
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');
const json = await res.json();
return Array.isArray(json) ? json : (json.data || []);
}
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={2} 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', 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={{ px: 3, py: 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 [page, setPage] = useState(0);
const [rpp, setRpp] = useState(10);
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;
});
const paged = displayBookings.slice(page * rpp, page * rpp + rpp);
const stats = {
total: bookings.length,
app: bookings.filter((b) => b.bookingsource === 'Customer_App').length,
crm: bookings.filter((b) => b.bookingsource === 'CRM_Console').length,
pending: bookings.filter((b) => b.status === 'Pending_Pickup').length
};
return (
<>
<PageHeader title="Bookings Management" breadcrumbs={[{ label: 'Bookings' }]} />
<Grid container spacing={2.5} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Bookings" value={stats.total} icon={LocalShippingOutlinedIcon} color="primary" caption="All sources" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="App Bookings" value={stats.app} icon={PhoneIphoneOutlinedIcon} color="primary" caption="Via customer app" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="CRM Bookings" value={stats.crm} icon={DesktopWindowsOutlinedIcon} color="primary" caption="Created in console" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Pending Pickup" value={stats.pending} icon={PendingActionsOutlinedIcon} color="primary" caption="Awaiting collection" /></Grid>
</Grid>
<Card sx={{ overflow: 'hidden' }}>
<Box
sx={{
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', gap: 1.5,
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
}}
>
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<LocalShippingOutlinedIcon fontSize="small" />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>All Bookings</Typography>
<Typography variant="caption" color="text.secondary">Track and manage every shipment booking</Typography>
</Box>
</Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<TextField
size="small" placeholder="Search bookings…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
sx={{ width: { xs: '100%', md: 300 } }}
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
/>
<Box sx={{ flexGrow: 1 }} />
{!loading && (
<Typography variant="body2" color="text.secondary">
{displayBookings.length} {displayBookings.length === 1 ? 'booking' : 'bookings'}
</Typography>
)}
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setFormOpen(true)} sx={{ flex: { xs: 1, md: 'none' } }}>
New Booking
</Button>
</Stack>
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={sourceFilter} onChange={(e, val) => { setSourceFilter(val); setPage(0); }} 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: 6 }}><CircularProgress /></Box>
) : displayBookings.length === 0 ? (
<EmptyState title="No bookings found" caption="Try a different filter or search term." />
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
<TableCell>Booking No</TableCell>
<TableCell>Delivery City</TableCell>
<TableCell>Provider</TableCell>
<TableCell>Source</TableCell>
<TableCell>Price</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paged.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>
)}
<TablePagination
component="div" count={displayBookings.length} page={page} onPageChange={(_, p) => setPage(p)}
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
/>
</Card>
<BookingFormDialog
open={formOpen}
onClose={() => setFormOpen(false)}
onSave={() => { loadData(); }}
clients={clients}
surveys={surveys}
/>
</>
);
}

View File

@@ -0,0 +1,592 @@
import { useState, useEffect } from 'react';
import {
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Typography, TextField, InputAdornment, CircularProgress, MenuItem,
Select, FormControl, OutlinedInput, 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 PriceCheckOutlinedIcon from '@mui/icons-material/PriceCheckOutlined';
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: 'primary.lighter', border: '1px solid', borderColor: 'primary.light', 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={{ px: 3, py: 2 }}>
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
<Button onClick={handleSave} disabled={saving} variant="contained">
{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 (
<>
<PageHeader
title="Logistics Pricing Board"
breadcrumbs={[{ label: 'Pricing Overview' }]}
/>
<Stack spacing={3}>
{/* Control Panel */}
<Card sx={{ overflow: 'hidden' }}>
<Box
sx={{
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', gap: 1.5,
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
}}
>
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<PriceCheckOutlinedIcon fontSize="small" />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>Pricing Matrix</Typography>
<Typography variant="caption" color="text.secondary">Manage carrier rates by weight slab and zone</Typography>
</Box>
</Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<TextField
size="small"
placeholder="Search weight slabs…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
sx={{ width: { xs: '100%', md: 300 } }}
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
/>
<Box sx={{ flexGrow: 1 }} />
{/* Active Provider Dropdown */}
<FormControl size="small" sx={{ minWidth: 220 }}>
<Select
value={selectedProvider || ''}
onChange={(e) => setSelectedProvider(e.target.value)}
displayEmpty
size="small"
IconComponent={StorefrontOutlinedIcon}
input={<OutlinedInput sx={{ fontWeight: 700 }} />}
>
{companies.length === 0 ? (
<MenuItem value="" disabled>Loading Providers...</MenuItem>
) : (
companies.map(c => (
<MenuItem key={c} value={c} sx={{ fontWeight: 600 }}>{c}</MenuItem>
))
)}
</Select>
</FormControl>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord({ company: selectedProvider, weight_slab: '', zone: '', rate: '' }); setIsCellEdit(false); setFormOpen(true); }} sx={{ whiteSpace: 'nowrap' }}>
Add Rate
</Button>
<Button
variant={isEditMode ? 'contained' : 'outlined'}
color={isEditMode ? 'inherit' : 'primary'}
startIcon={<EditOutlinedIcon />}
onClick={() => setIsEditMode(!isEditMode)}
sx={{ whiteSpace: 'nowrap', ...(isEditMode && { bgcolor: 'grey.800', color: 'white', '&:hover': { bgcolor: 'grey.900' } }) }}
>
{isEditMode ? 'Done' : 'Edit'}
</Button>
</Stack>
</Card>
{/* Minimalist Collapsible Provider Profile Widget */}
{selectedProvider && providerSurveys.length > 0 && (
<Card
elevation={0}
sx={{
border: '1px solid',
borderColor: locationsOpen ? 'primary.main' : 'grey.200',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
borderColor: 'primary.main',
boxShadow: '0 8px 30px rgba(192, 18, 39, 0.08)',
}
}}
>
<Stack
direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ p: { xs: 2, sm: 2.5 }, cursor: 'pointer', userSelect: 'none' }}
alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
onClick={() => setLocationsOpen(!locationsOpen)}
>
{/* Avatar & Title */}
<Stack direction="row" spacing={2} alignItems="center" sx={{ minWidth: 260 }}>
<Avatar sx={{ width: 42, height: 42, bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 800, borderRadius: 2 }}>
{(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: 'primary.main' }} />
{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: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
<Chip size="small" label="Mid Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
<Chip size="small" label="Last Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
</Stack>
<Button
onClick={(e) => { e.stopPropagation(); setLocationsOpen(!locationsOpen); }}
sx={{
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
color: locationsOpen ? 'primary.main' : 'grey.700',
bgcolor: locationsOpen ? 'primary.lighter' : 'grey.100',
'&:hover': { bgcolor: 'primary.lighter', color: 'primary.main' }
}}
endIcon={locationsOpen ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
>
{locationsOpen ? 'Hide Coverage' : 'View Coverage Map'}
</Button>
</Stack>
</Stack>
<Collapse in={locationsOpen} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, borderTop: '1px dashed', borderColor: 'grey.200', bgcolor: 'grey.50' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
<PlaceOutlinedIcon sx={{ color: 'primary.main', 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, flexWrap: 'wrap' }}>
{providerSurveys.map((s, idx) => (
<Chip
key={s.id ?? idx}
label={s.area || s.address}
sx={{
fontWeight: 600,
bgcolor: 'background.paper', color: 'grey.800', border: '1px solid', borderColor: 'grey.300',
'&:hover': { borderColor: 'primary.main', color: 'primary.main', bgcolor: 'primary.lighter' },
transition: 'border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease'
}}
/>
))}
</Box>
</Box>
</Collapse>
</Card>
)}
{/* Pricing Data Grid */}
<Card sx={{ overflow: 'hidden' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 10 }}>
<CircularProgress size={40} sx={{ color: 'primary.light' }} />
</Box>
) : (
<TableContainer>
<Table sx={{ minWidth: 700 }}>
<TableHead>
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
<TableCell>Weight Slab</TableCell>
{providerZones.map(zone => (
<TableCell key={zone} align="center">
{zone}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{providerZones.length === 0 ? (
<TableRow>
<TableCell colSpan={1} 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}
hover
sx={{ '&:last-child td': { borderBottom: 0 } }}
>
<TableCell sx={{ fontWeight: 600, color: 'grey.800' }}>
{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={{
cursor: isEditMode ? 'pointer' : 'default',
transition: 'background 0.15s',
'&:hover': isEditMode ? { bgcolor: 'primary.lighter' } : {},
}}
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 ? 'primary.light' : 'grey.300'),
fontSize: hasRate ? '0.95rem' : '0.85rem',
bgcolor: hasRate ? 'success.lighter' : '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: 'primary.light' }} />
)}
</Box>
</Tooltip>
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</Card>
</Stack>
<PricingFormDialog
open={formOpen}
onClose={() => setFormOpen(false)}
onSave={() => loadData()}
initialData={editingRecord}
isCell={isCellEdit}
pricingList={pricingList}
providerColumns={providerZones}
providerUsesServiceType={providerUsesServiceType}
/>
</>
);
}

853
src/pages/survey/Survey.jsx Normal file
View File

@@ -0,0 +1,853 @@
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,
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';
import EmptyState from '@/components/EmptyState';
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 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: 'primary.lighter' }}>
<Icon sx={{ fontSize: 18, color: 'primary.main' }} />
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography>
</Stack>
<Stack spacing={1.75} 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={{ px: 3, py: 2 }}>
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
<Button onClick={handleSave} disabled={saving} variant="contained">
{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, border: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', transition: 'border-color 0.2s ease, box-shadow 0.2s ease', '&:hover': { borderColor: 'primary.main', boxShadow: '0 4px 12px rgba(192, 18, 39, 0.05)' } }}>
<Grid
container
spacing={2}
alignItems="center"
onClick={() => setOpen(!open)}
sx={{ cursor: 'pointer', userSelect: 'none' }}
>
{/* Left: Location & Contact */}
<Grid item xs={12} md={4.5}>
<Stack direction="row" spacing={1.5} alignItems="center">
<Box sx={{ p: 1, borderRadius: 1.5, bgcolor: 'primary.lighter', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PlaceOutlinedIcon sx={{ color: 'primary.main', fontSize: 18 }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900', lineHeight: 1.2 }}>
{row.area || 'Unknown Area'}
</Typography>
<Typography variant="caption" sx={{ color: 'grey.600', display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<PhoneOutlinedIcon sx={{ fontSize: 12 }} /> {row.phone || '—'}
</Typography>
</Box>
</Stack>
</Grid>
{/* Middle: Rates */}
<Grid item xs={6} md={3.5}>
<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={
!row.rate_per_kg ||
row.rate_per_kg.toLowerCase().includes('not answered') ||
row.rate_per_kg.toLowerCase().includes('wrong number')
? 'warning'
: 'success'
}
/>
</Box>
</Grid>
{/* Logistics */}
<Grid item xs={6} md={2}>
<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>
</Grid>
{/* Right: Actions */}
<Grid item xs={12} md={2} sx={{ display: 'flex', justifyContent: { xs: 'flex-start', md: 'flex-end' }, alignItems: 'center', gap: 0.5 }}>
<Button size="small" sx={{ borderRadius: 1.5, color: open ? 'primary.main' : 'grey.700', bgcolor: open ? 'primary.lighter' : 'transparent', '&:hover': { bgcolor: 'primary.lighter', color: 'primary.main' } }}>
{open ? 'Hide Info' : 'More Info'}
</Button>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }} sx={{ color: 'grey.400', '&:hover': { color: 'primary.main', bgcolor: 'primary.lighter' } }}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }} sx={{ color: 'grey.400', '&:hover': { color: 'error.main', bgcolor: 'error.lighter' } }}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Grid>
</Grid>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ mt: 2, pt: 2, 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: 'primary.main', borderColor: 'primary.light', bgcolor: 'primary.lighter',
'&:hover': { borderColor: 'primary.main', bgcolor: 'primary.lighter' }
}}
>
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={{
borderColor: open ? 'primary.main' : 'grey.200',
p: 2,
mb: 2,
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: 'primary.main',
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} alignItems="center">
<Avatar sx={{ width: 42, height: 42, bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 800, borderRadius: 2 }}>
{(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: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
<Chip size="small" label="Mid Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
<Chip size="small" label="Last Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
</Stack>
<Stack direction="row" spacing={1.5} alignItems="center">
<Button
sx={{
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
color: open ? 'primary.main' : 'grey.700',
bgcolor: open ? 'primary.lighter' : 'grey.100',
'&:hover': { bgcolor: 'primary.lighter', color: 'primary.main' }
}}
endIcon={open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
>
{open ? 'Hide Locations' : 'View Locations'}
</Button>
</Stack>
</Stack>
</Stack>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ mt: 2, pt: 2, borderTop: '2px dashed', borderColor: 'grey.200' }}>
<Stack spacing={1.5}>
{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 (
<>
<PageHeader
title="Field Surveys"
breadcrumbs={[{ label: 'Survey Management' }]}
/>
<Grid container spacing={2.5} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<StatCard
accent title="Total Enquiries"
value={loading ? '…' : stats.total}
icon={AssignmentOutlinedIcon} color="primary"
caption="All field surveys"
/>
</Grid>
<Grid item xs={12} md={4}>
<StatCard
accent title="Rates Quoted"
value={loading ? '…' : stats.quoted}
icon={StorefrontOutlinedIcon} color="success"
caption={stats.total ? `${Math.round((stats.quoted / stats.total) * 100)}% of total` : '0% of total'}
/>
</Grid>
<Grid item xs={12} md={4}>
<StatCard
accent title="Missing Info"
value={loading ? '…' : stats.missing}
icon={PhoneOutlinedIcon} color="warning"
caption={stats.total ? `${Math.round((stats.missing / stats.total) * 100)}% of total` : '0% of total'}
/>
</Grid>
</Grid>
<Card sx={{ overflow: 'hidden' }}>
{/* Header Section */}
<Box
sx={{
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', gap: 1.5,
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
}}
>
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<AssignmentOutlinedIcon fontSize="small" />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>
Survey Records
</Typography>
<Typography variant="caption" color="text.secondary">
Manage business enquiry and survey data from the field
</Typography>
</Box>
</Box>
{/* Toolbar Section */}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<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: 300 } }}
InputProps={{
startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>,
}}
/>
<Box sx={{ flexGrow: 1 }} />
{!loading && (
<Typography variant="body2" color="text.secondary">
{filtered.length} records
</Typography>
)}
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord(null); setFormOpen(true); }} sx={{ flex: { xs: 1, md: 'none' } }}>
Add New Survey
</Button>
</Stack>
{/* List Section */}
<Box sx={{ p: 2, bgcolor: 'grey.50' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<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>
) : (
<EmptyState icon={AssignmentOutlinedIcon} title="No survey records found." caption="Try adjusting your search filters." />
)}
</Box>
<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:"
/>
</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>
</>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@ import {
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Chip, Link, Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Chip, Link,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
TablePagination, Typography, CircularProgress, Alert, Tooltip, Divider, useMediaQuery, TablePagination, Typography, CircularProgress, Alert, Tooltip, Divider, useMediaQuery,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Grid
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
@@ -21,28 +21,16 @@ import ManageAccountsOutlinedIcon from '@mui/icons-material/ManageAccountsOutlin
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined'; import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import StatusChip from '@/components/StatusChip'; import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount'; import TabLabelCount from '@/components/TabLabelCount';
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant'; import { fetchUsers, deleteUser } from '@/utils/apiClient';
import { toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
import UserFormDialog from './UserFormDialog'; import UserFormDialog from './UserFormDialog';
// Map a raw Qdrant point from doormile_auth to a flat team-user row.
function toUser(point) {
const p = point.payload || {};
return {
id: point.id,
name: p.name || '—',
email: p.email || '',
phone: p.phone || '',
role: p.role || 'unknown',
pin: p.pin || ''
};
}
const titleCase = (s) => String(s || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
// Per-role accent colour + icon, used for the avatar badge and role chip. // Per-role accent colour + icon, used for the avatar badge and role chip.
const ROLE_META = { const ROLE_META = {
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon }, admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
@@ -98,7 +86,7 @@ function RoleCell({ role }) {
// Mobile presentation of a user row — a self-contained card instead of a wide table row. // Mobile presentation of a user row — a self-contained card instead of a wide table row.
function UserCard({ row, index, onEdit, onDelete }) { function UserCard({ row, index, onEdit, onDelete }) {
return ( return (
<Box sx={{ p: 1.75, borderRadius: 3, border: 1, borderColor: 'divider', bgcolor: 'background.paper' }}> <Box sx={{ p: 2, borderRadius: 3, border: 1, borderColor: 'divider', bgcolor: 'background.paper' }}>
<Stack direction="row" spacing={1.25} alignItems="flex-start"> <Stack direction="row" spacing={1.25} alignItems="flex-start">
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
<UserIdentity name={row.name} email={row.email} role={row.role} /> <UserIdentity name={row.name} email={row.email} role={row.role} />
@@ -156,7 +144,7 @@ export default function TeamUsers() {
const load = () => { const load = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
fetchPoints(COLLECTIONS.teamUsers) fetchUsers()
.then((points) => setUsers(points.map(toUser))) .then((points) => setUsers(points.map(toUser)))
.catch((e) => setError(e.message || 'Failed to load team users')) .catch((e) => setError(e.message || 'Failed to load team users'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -178,6 +166,15 @@ export default function TeamUsers() {
return c; return c;
}, [users]); }, [users]);
const roleCounts = useMemo(() => {
const c = { admin: 0, manager: 0, rep: 0, support: 0 };
users.forEach((u) => {
const role = String(u.role || '').toLowerCase();
if (role in c) c[role] += 1;
});
return c;
}, [users]);
const filtered = useMemo( const filtered = useMemo(
() => () =>
users.filter((u) => { users.filter((u) => {
@@ -196,7 +193,7 @@ export default function TeamUsers() {
const confirmDelete = async () => { const confirmDelete = async () => {
setDeleting(true); setDeleting(true);
try { try {
await deletePoint(COLLECTIONS.teamUsers, toDelete.id); await deleteUser(toDelete.id);
setToDelete(null); setToDelete(null);
load(); load();
} catch (e) { } catch (e) {
@@ -219,10 +216,17 @@ export default function TeamUsers() {
} }
/> />
<Grid container spacing={2.5} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Users" value={users.length} icon={GroupsOutlinedIcon} color="primary" caption="All console members" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Admins" value={roleCounts.admin} icon={AdminPanelSettingsOutlinedIcon} color="primary" caption="Full access" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Managers" value={roleCounts.manager} icon={ManageAccountsOutlinedIcon} color="primary" caption="Team oversight" /></Grid>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Reps & Support" value={roleCounts.rep + roleCounts.support} icon={SupportAgentOutlinedIcon} color="primary" caption="Frontline staff" /></Grid>
</Grid>
<Card sx={{ overflow: 'hidden' }}> <Card sx={{ overflow: 'hidden' }}>
<Box <Box
sx={{ sx={{
px: { xs: 1.75, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider', px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', gap: 1.5, display: 'flex', alignItems: 'center', gap: 1.5,
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)` background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
}} }}
@@ -236,7 +240,7 @@ export default function TeamUsers() {
</Box> </Box>
</Box> </Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: { xs: 1.5, sm: 2 } }} alignItems={{ md: 'center' }}> <Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<TextField <TextField
size="small" placeholder="Search by name, email, phone…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} size="small" placeholder="Search by name, email, phone…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
sx={{ width: { xs: '100%', md: 300 } }} sx={{ width: { xs: '100%', md: 300 } }}
@@ -248,7 +252,7 @@ export default function TeamUsers() {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{filtered.length} {filtered.length === 1 ? 'user' : 'users'} {filtered.length} {filtered.length === 1 ? 'user' : 'users'}
</Typography> </Typography>
<Chip size="small" label="live · doormile_auth" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} /> <Chip size="small" label="live · PostgreSQL" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
</Stack> </Stack>
)} )}
</Stack> </Stack>
@@ -268,7 +272,7 @@ export default function TeamUsers() {
) : paged.length === 0 ? ( ) : paged.length === 0 ? (
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." /> <EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
) : isMobile ? ( ) : isMobile ? (
<Stack spacing={1.25} sx={{ p: { xs: 1.5, sm: 2 } }}> <Stack spacing={1.5} sx={{ p: 2 }}>
{paged.map((row, i) => ( {paged.map((row, i) => (
<UserCard <UserCard
key={row.id} key={row.id}
@@ -340,7 +344,7 @@ export default function TeamUsers() {
<DialogTitle>Delete user?</DialogTitle> <DialogTitle>Delete user?</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_auth collection. This cannot be undone. This will permanently remove <strong>{toDelete?.name}</strong> from the PostgreSQL database. This cannot be undone.
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}> <DialogActions sx={{ px: 3, py: 2 }}>

View File

@@ -5,11 +5,11 @@ import {
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant'; import { createUser, updateUser } from '@/utils/apiClient';
const ROLES = ['admin', 'rep', 'manager']; const ROLES = ['admin', 'rep', 'manager'];
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '' }; const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' };
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts); const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
@@ -33,22 +33,24 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
const handleSave = async () => { const handleSave = async () => {
if (!form.name.trim()) { setError('Name is required.'); return; } if (!form.name.trim()) { setError('Name is required.'); return; }
if (!form.email.trim()) { setError('Email is required.'); return; } if (!form.email.trim()) { setError('Email is required.'); return; }
if (!isEdit && !form.password.trim()) { setError('Password is required.'); return; }
setSaving(true); setSaving(true);
setError(null); setError(null);
const payload = { const payload = {
name: form.name.trim(), first_name: form.name.trim(),
email: form.email.trim(), email: form.email.trim(),
phone: form.phone, phone: form.phone,
role: form.role, role: form.role,
...(form.pin ? { pin: String(form.pin) } : {}) ...(form.password ? { password: form.password } : {}),
...(form.pin ? { pin: String(form.pin) } : {}),
}; };
try { try {
if (isEdit) { if (isEdit) {
await setPayload(COLLECTIONS.teamUsers, initial.id, payload); await updateUser(initial.id, payload);
} else { } else {
await createPoint(COLLECTIONS.teamUsers, payload); await createUser(payload);
} }
onSaved(); onSaved();
} catch (e) { } catch (e) {
@@ -76,6 +78,14 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid> <Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth size="small" type="password"
label={isEdit ? 'New Password (optional)' : 'Password *'}
value={form.password}
onChange={set('password')}
/>
</Grid>
</Grid> </Grid>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}> <DialogActions sx={{ px: 3, py: 2 }}>

View File

@@ -1,25 +1,49 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton, useMediaQuery MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton, Autocomplete, Snackbar, useMediaQuery
} from '@mui/material'; } from '@mui/material';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant'; import MyLocationIcon from '@mui/icons-material/MyLocation';
import { createClient, updateClient } from '@/utils/apiClient';
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other']; const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
const STATUSES = ['newClient', 'contacted', 'onboarded', 'lost']; const STATUSES = ['newClient', 'contacted', 'onboarded', 'lost'];
const FREQUENCIES = ['Daily', 'Weekly', 'Fortnightly', 'Monthly', 'Occasional']; const FREQUENCIES = ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'On-demand'];
const CONSENTS = ['basicOnly', 'full', 'none']; const CONSENTS = ['basicOnly', 'full', 'none'];
const PROVIDERS = [
'Blue Dart', 'Delhivery', 'DTDC', 'India Post / Speed Post', 'The Professional Couriers', 'XpressBees', 'Ecom Express', 'Shadowfax',
'Safexpress', 'VRL Logistics', 'TCI (Transport Corporation of India)', 'Om Logistics', 'Best Roadways',
'MSS (Mettur Super Services)', 'ABT Travels & Logistics', 'Navata Road Transport', 'KRS (Kerala Roadways)', 'Parveen Travels / Parveen Express', 'SRM Transports', 'KPN Travels & KPN Speed Parcel', 'SRS Travels',
'Hindusthan Travels', 'City Travels', 'Essaar Travels', 'No. 1 Air Travels', 'A1 Travels', 'Krish Travels', 'Hebron Transports', 'Horma Travels', 'Vivegam Travels', 'PSS Transport', 'SRT (Renugambal Travels)', 'Thamarai Bus Transports', 'Rathimeena Travels', 'Ganesh Travels', 'John Kennedy Bus Service', 'Arun Travel', 'Saaji Meera Roadways', 'ARC Parcel Service', 'Chakra Travels & Parcel Service', 'MVA Parcel And Bus Service',
'Shrinath Travels & Cargo', 'Hans Travels', 'Zingbus', 'Kalpana Travels / City Land Travels', 'Trackon Couriers', 'North India Transways', 'RSRTC Cargo', 'UPSRTC Cargo'
];
const CITIES = [
'Chennai', 'Coimbatore', 'Madurai', 'Tiruchirappalli', 'Salem', 'Tuticorin', 'Tirupur', 'Erode', 'Vellore', 'Tirunelveli', 'Thanjavur', 'Dindigul', 'Hosur', 'Nagercoil', 'Karur', 'Namakkal', 'Kanchipuram', 'Cuddalore', 'Thoothukudi',
'Bengaluru', 'Mysuru', 'Mangaluru', 'Hubli', 'Belagavi', 'Kalaburagi', 'Davangere', 'Ballari',
'Kochi', 'Thiruvananthapuram', 'Kozhikode', 'Kannur', 'Thrissur', 'Kollam',
'Hyderabad', 'Warangal', 'Visakhapatnam', 'Vijayawada', 'Tirupati', 'Guntur', 'Rajahmundry', 'Nellore',
'Mumbai', 'Pune', 'Nagpur', 'Nashik', 'Aurangabad', 'Kolhapur', 'Solapur',
'New Delhi', 'Gurugram', 'Noida', 'Faridabad', 'Chandigarh', 'Amritsar', 'Ludhiana', 'Jalandhar',
'Ahmedabad', 'Surat', 'Vadodara', 'Rajkot', 'Bhavnagar', 'Jamnagar',
'Jaipur', 'Jodhpur', 'Udaipur', 'Kota', 'Bikaner',
'Lucknow', 'Kanpur', 'Varanasi', 'Agra', 'Prayagraj', 'Gorakhpur', 'Bhopal', 'Indore', 'Gwalior', 'Jabalpur', 'Raipur',
'Kolkata', 'Siliguri', 'Durgapur', 'Patna', 'Gaya', 'Ranchi', 'Jamshedpur', 'Bhubaneswar', 'Cuttack', 'Guwahati', 'Dibrugarh', 'Agartala', 'Imphal', 'Shillong', 'Aizawl', 'Dimapur',
'Dehradun', 'Haridwar', 'Shimla', 'Srinagar', 'Jammu', 'Leh', 'Panaji', 'Puducherry', 'Port Blair'
];
const EMPTY = { const EMPTY = {
name: '', phone: '', city: '', businessState: '', businessType: 'retail', status: 'newClient', name: '', email: '', password: '', phone: '', city: '', businessState: '',
frequency: 'Daily', parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '', businessType: 'retail', status: 'newClient', frequency: 'Daily',
parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
logisticsSegment: '', transitFrom: '', transitTo: '', neighbourhood: '', logisticsSegment: '', transitFrom: '', transitTo: '', neighbourhood: '',
surveyAddress: '', surveyLat: '', surveyLng: '', dataConsent: 'basicOnly', notes: '' surveyAddress: '', surveyLat: '', surveyLng: '', dataConsent: 'full', notes: '', pincode: ''
}; };
// Ensure a select always has its current value among the options. const formatOption = (s) => typeof s === 'string' ? s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1').trim() : s;
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts); const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) { export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
@@ -29,6 +53,47 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
const [form, setForm] = useState(EMPTY); const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [gettingLocation, setGettingLocation] = useState(false);
const handleGPS = () => {
if (!navigator.geolocation) {
alert("Geolocation is not supported by your browser");
return;
}
setGettingLocation(true);
navigator.geolocation.getCurrentPosition(async (pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setForm((f) => ({ ...f, surveyLat: lat, surveyLng: lng }));
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
const data = await res.json();
if (data && data.address) {
const address = data.address;
const city = address.city || address.town || address.village || address.county || '';
const state = address.state || '';
const pincode = address.postcode || '';
const display = data.display_name || '';
setForm((f) => ({
...f,
city: city || f.city,
businessState: state || f.businessState,
pincode: pincode || f.pincode,
surveyAddress: display || f.surveyAddress
}));
}
} catch (e) {
console.error("Reverse geocoding failed", e);
} finally {
setGettingLocation(false);
}
}, (err) => {
alert("Unable to retrieve your location");
setGettingLocation(false);
});
};
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -48,7 +113,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng); const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng);
const payload = { const payload = {
name: form.name.trim(), first_name: form.name.trim(),
phone: form.phone, phone: form.phone,
city: form.city, city: form.city,
businessState: form.businessState, businessState: form.businessState,
@@ -63,25 +128,22 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
transitFrom: form.transitFrom, transitFrom: form.transitFrom,
transitTo: form.transitTo, transitTo: form.transitTo,
neighbourhood: form.neighbourhood, neighbourhood: form.neighbourhood,
pincode: form.pincode,
surveyAddress: form.surveyAddress, surveyAddress: form.surveyAddress,
surveyZone: form.neighbourhood,
dataConsent: form.dataConsent, dataConsent: form.dataConsent,
notes: form.notes, notes: form.notes,
lastUpdated: new Date().toISOString().slice(0, 10), lastUpdated: new Date().toISOString().slice(0, 10),
...(lat != null ? { surveyLat: lat } : {}), registration_source: 'web',
...(lng != null ? { surveyLng: lng } : {}), ...(form.email.trim() ? { email: form.email.trim() } : {}),
...(lat != null && lng != null ? { surveyGeo: { lat, lon: lng } } : {}) ...(lat != null ? { survey_lat: lat } : {}),
...(lng != null ? { survey_long: lng } : {}),
}; };
try { try {
if (isEdit) { if (isEdit) {
await setPayload(COLLECTIONS.clients, initial.id, payload); await updateClient(initial.id, payload);
} else { } else {
await createPoint(COLLECTIONS.clients, { await createClient({ ...payload, ...(form.password ? { password: form.password } : {}) });
...payload,
clientId: `client_${Date.now()}`,
surveySubmitted: false
});
} }
onSaved(); onSaved();
} catch (e) { } catch (e) {
@@ -92,36 +154,59 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
}; };
return ( return (
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth fullScreen={fullScreen}> <>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Snackbar
{isEdit ? 'Edit Client' : 'Add Client'} open={!!error}
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton> autoHideDuration={6000}
</DialogTitle> onClose={() => setError(null)}
<DialogContent dividers> anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} sx={{ zIndex: 9999 }}
>
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
{error}
</Alert>
</Snackbar>
<Typography variant="overline" color="text.secondary">Business</Typography> <Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth fullScreen={typeof fullScreen !== 'undefined' ? fullScreen : false}>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{isEdit ? 'Edit Client' : 'Add Client'}
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
</DialogTitle>
<DialogContent dividers>
<Typography variant="overline" color="text.secondary">Business</Typography>
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}> <Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid> <Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid> <Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email" value={form.email} onChange={set('email')} /></Grid>
{!isEdit && (
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Password" type="password" value={form.password} onChange={set('password')} /></Grid>
)}
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}> <TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}>
{withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)} {withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}> <TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}>
{withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)} {withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}> <TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}>
{withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)} {withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Parcel Volume" value={form.parcelVolume} onChange={set('parcelVolume')} /></Grid> <Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Parcel Volume" value={form.parcelVolume} onChange={set('parcelVolume')} /></Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Active Contracts" value={form.activeContracts} onChange={set('activeContracts')} /></Grid> <Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Active Contracts" value={form.activeContracts} onChange={set('activeContracts')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Current Provider" value={form.provider} onChange={set('provider')} /></Grid> <Grid item xs={12} sm={6}>
<Autocomplete
autoHighlight
options={PROVIDERS}
value={form.provider || null}
onChange={(e, newValue) => setForm((f) => ({ ...f, provider: newValue || '' }))}
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Current Provider" />}
/>
</Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Efficiency" value={form.efficiency} onChange={set('efficiency')} /></Grid> <Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Efficiency" value={form.efficiency} onChange={set('efficiency')} /></Grid>
<Grid item xs={12}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid> <Grid item xs={12}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid>
</Grid> </Grid>
@@ -129,13 +214,50 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
<Divider sx={{ my: 1.5 }} /> <Divider sx={{ my: 1.5 }} />
<Typography variant="overline" color="text.secondary">Location & Transit</Typography> <Typography variant="overline" color="text.secondary">Location & Transit</Typography>
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}> <Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="City" value={form.city} onChange={set('city')} /></Grid> <Grid item xs={12} sm={6}>
<Autocomplete
autoHighlight
options={CITIES}
value={form.city || null}
onChange={(e, newValue) => setForm((f) => ({ ...f, city: newValue || '' }))}
renderInput={(params) => <TextField {...params} fullWidth size="small" label="City" />}
/>
</Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid> <Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Pincode" value={form.pincode} onChange={set('pincode')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid> <Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit From" value={form.transitFrom} onChange={set('transitFrom')} /></Grid> <Grid item xs={12} sm={6}>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit To" value={form.transitTo} onChange={set('transitTo')} /></Grid> <Autocomplete
autoHighlight
options={CITIES}
value={form.transitFrom || null}
onChange={(e, newValue) => setForm((f) => ({ ...f, transitFrom: newValue || '' }))}
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit From" />}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
autoHighlight
options={CITIES}
value={form.transitTo || null}
onChange={(e, newValue) => setForm((f) => ({ ...f, transitTo: newValue || '' }))}
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit To" />}
/>
</Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Latitude" value={form.surveyLat} onChange={set('surveyLat')} /></Grid> <Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Latitude" value={form.surveyLat} onChange={set('surveyLat')} /></Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Longitude" value={form.surveyLng} onChange={set('surveyLng')} /></Grid> <Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Longitude" value={form.surveyLng} onChange={set('surveyLng')} /></Grid>
<Grid item xs={12} sm={6} display="flex" alignItems="center">
<Button
variant="outlined"
size="small"
onClick={handleGPS}
disabled={gettingLocation}
startIcon={gettingLocation ? <CircularProgress size={16} /> : <MyLocationIcon />}
fullWidth
>
Get GPS Location
</Button>
</Grid>
<Grid item xs={12}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid> <Grid item xs={12}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
</Grid> </Grid>
@@ -144,7 +266,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
<Grid container spacing={2} sx={{ mt: 0 }}> <Grid container spacing={2} sx={{ mt: 0 }}>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField select fullWidth size="small" label="Data Consent" value={form.dataConsent} onChange={set('dataConsent')}> <TextField select fullWidth size="small" label="Data Consent" value={form.dataConsent} onChange={set('dataConsent')}>
{withValue(CONSENTS, form.dataConsent).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)} {withValue(CONSENTS, form.dataConsent).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
</TextField> </TextField>
</Grid> </Grid>
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid> <Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid>
@@ -157,5 +279,6 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</>
); );
} }

View File

@@ -34,62 +34,11 @@ import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount'; import TabLabelCount from '@/components/TabLabelCount';
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant'; import { fetchClients, deleteClient } from '@/utils/apiClient';
import { toClient } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
import ClientFormDialog from './ClientFormDialog'; import ClientFormDialog from './ClientFormDialog';
const generateLogicalId = (id) => {
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
};
// Map a raw Qdrant point from doormile_clients to a flat client row.
function toClient(point) {
const p = point.payload || {};
// Qdrant point.id is usually a UUID.
// We generate a clean CLI-XXXXXX format based on it.
const logicalId = generateLogicalId(point.id);
return {
id: point.id,
logicalId,
// Force override the ugly payload client_timestamp string with the clean ID
clientId: logicalId,
name: p.name || '—',
phone: p.phone || '',
city: p.city || '',
businessState: p.businessState || '',
businessType: p.businessType || '',
status: p.status || 'unknown',
parcelVolume: p.parcelVolume ?? 0,
activeContracts: p.activeContracts ?? 0,
frequency: p.frequency || '',
provider: p.provider || '',
efficiency: p.efficiency || '',
logisticsSegment: p.logisticsSegment || '',
transitFrom: p.transitFrom || '',
transitTo: p.transitTo || '',
neighbourhood: p.neighbourhood || p.surveyZone || '',
surveyAddress: p.surveyAddress || '',
surveyLat: p.surveyLat ?? p.surveyGeo?.lat ?? '',
surveyLng: p.surveyLng ?? p.surveyGeo?.lon ?? '',
dataConsent: p.dataConsent || '',
lastUpdated: p.lastUpdated || '',
notes: p.notes || ''
};
}
// Humanize raw payload tokens like `basicOnly`, `newClient`, `first_mile` → "Basic Only".
const humanize = (s) =>
String(s || '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
const titleCase = humanize;
// Map categorical enum values to a semantic palette color. // Map categorical enum values to a semantic palette color.
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default'); const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
const efficiencyTone = (v) => { const efficiencyTone = (v) => {
@@ -191,7 +140,7 @@ function ClientDetail({ row, onEdit }) {
alignItems={{ xs: 'flex-start', sm: 'center' }} alignItems={{ xs: 'flex-start', sm: 'center' }}
spacing={1.5} spacing={1.5}
sx={{ sx={{
px: 2.5, py: 1.75, borderBottom: 1, borderColor: 'divider', px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)` background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)`
}} }}
> >
@@ -482,16 +431,28 @@ export default function Tenants() {
const [toDelete, setToDelete] = useState(null); const [toDelete, setToDelete] = useState(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const load = () => { const load = (silent = false) => {
setLoading(true); if (!silent) {
setError(null); setLoading(true);
fetchPoints(COLLECTIONS.clients) setError(null);
}
fetchClients()
.then((points) => setClients(points.map(toClient))) .then((points) => setClients(points.map(toClient)))
.catch((e) => setError(e.message || 'Failed to load clients')) .catch((e) => {
.finally(() => setLoading(false)); if (!silent) setError(e.message || 'Failed to load clients');
})
.finally(() => {
if (!silent) setLoading(false);
});
}; };
useEffect(() => { load(); }, []); useEffect(() => {
load();
const intervalId = setInterval(() => {
load(true); // Silent poll every 10 seconds
}, 10000);
return () => clearInterval(intervalId);
}, []);
const stats = useMemo(() => ({ const stats = useMemo(() => ({
total: clients.length, total: clients.length,
@@ -534,7 +495,7 @@ export default function Tenants() {
const confirmDelete = async () => { const confirmDelete = async () => {
setDeleting(true); setDeleting(true);
try { try {
await deletePoint(COLLECTIONS.clients, toDelete.id); await deleteClient(toDelete.id);
setToDelete(null); setToDelete(null);
load(); load();
} catch (e) { } catch (e) {
@@ -567,7 +528,7 @@ export default function Tenants() {
<Card sx={{ overflow: 'hidden' }}> <Card sx={{ overflow: 'hidden' }}>
<Box <Box
sx={{ sx={{
px: 2.5, py: 2, borderBottom: 1, borderColor: 'divider', px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', gap: 1.5, display: 'flex', alignItems: 'center', gap: 1.5,
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)` background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
}} }}

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

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