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
# Install dependencies
npm install
npm run dev # http://localhost:3000
npm run build # production build → dist/
npm run preview # preview the production build
# Run the development server (default port: 5173 or similar)
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 |
| --- | --- |
| Overview | Dashboard |
| Orders | Orders list · Order Details (tracking + timeline) · Create Order · Create Multiple Orders · Assign Orders |
| Deliveries | Deliveries (expandable products, status tabs) |
| Network | Tenants · Create Client · Customers · Create Customer · Pricing · Riders · Create Rider · Edit Rider |
| Reports | Order Summary · Order Details · Riders Summary · Riders Logs (live map) |
| Finance | Invoices · Invoice Preview (printable A4) · Requests |
| Account | User Profile |
| Auth & states | Login · 404 · 500 · Under Construction · Coming Soon |
| Route Path | Page Component | Description |
| --- | --- | --- |
| `/dashboard` | `Dashboard.jsx` | Overview of registered clients, active contracts, total parcel volume, status donuts, and quick-view charts. |
| `/tenants` | `Tenants.jsx` | Management view for clients, including expandable details (parcel volume, logistics segments, transit routes, and coordinates map link). |
| `/survey` | `Survey.jsx` | Provider survey records mapping competitors, branch locations, and rate-per-kg answers in an aligned grid layout. |
| `/pricing` | `Pricing.jsx` | Pricing matrix slabs and logistics carrier rates management with search and active-provider selectors. |
| `/bookings` | `Bookings.jsx` | Courier bookings creation and management dialogs, along with pricing checks. |
| `/app-users` | `TeamUsers.jsx` | Team member list, user roles configuration, and initial permission setup. |
| `/settings` | `Settings.jsx` | Preferences dashboard covering organization profiles, system notification switches, and security/password updates. |
## 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`.
- **Public Sans** type scale, 610px radii, soft `0 1px 4px` card shadows, and a signature **red glow** on primary CTAs.
- All tuning lives in `src/theme/` (`palette.js`, `typography.js`, `shadows.js`, `componentsOverride.js`).
- **Public Sans** typography scale, clean border-radii (`8px` to `16px`), soft shadows, and subtle responsive layouts (flex-based grid column alignment).
- Theme configuration resides in `src/theme/`.
## Project structure
## Project Folders
```
src/
theme/ Doormile red theme + MUI component overrides
layout/ MainLayout (red header + collapsible sidebar) · MinimalLayout
menu/ sidebar nav config
components/ PageHeader, StatCard, StatusChip, MainCard, EmptyState,
UserAvatar, MapPlaceholder, Logo, charts/ (Area, Donut)
data/mock.js static demo data powering every screen
pages/ all 27 screens (lazy-loaded)
App.jsx router · main.jsx app entry
components/ Reusable UI widgets (PageHeader, StatCard, StatusChip, EmptyState)
layout/ MainLayout (header + collapsible sidebar) & MinimalLayout
menu/ Sidebar navigation mappings (`navItems.jsx`)
pages/ CRM modules and page components (dashboard, tenants, survey, pricing, bookings, team, settings)
theme/ Doormile custom theme configurations and component style overrides
utils/ API fetching wrapper client
App.jsx App router and route definitions
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="/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>

View File

@@ -1,9 +1,40 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
const INACTIVITY_LIMIT_MS = 10 * 60 * 1000; // 10 minutes
export default function AuthGuard({ children }) {
const token = localStorage.getItem('auth_token');
if (!token) {
const navigate = useNavigate();
const loggedIn = localStorage.getItem('logged_in');
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 />;
}

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import AttachMoneyOutlinedIcon from '@mui/icons-material/AttachMoneyOutlined';
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
@@ -11,7 +12,10 @@ const navItems = [
items: [
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
{ 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 UserAvatar from '@/components/UserAvatar';
import EmptyState from '@/components/EmptyState';
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
import bgImage from '@/assets/premium_logistics_bg.png';
const titleCase = (s) =>
String(s || '').replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
import { fetchClients, fetchUsers } from '@/utils/apiClient';
import { toClient, toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C'];
const generateLogicalId = (id) => {
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
};
function toClient(point) {
const p = point.payload || {};
return {
id: point.id,
logicalId: generateLogicalId(point.id),
name: p.name || '—',
businessType: p.businessType || '',
city: p.city || '',
businessState: p.businessState || '',
status: p.status || 'unknown',
parcelVolume: Number(p.parcelVolume) || 0,
activeContracts: Number(p.activeContracts) || 0,
lastUpdated: p.lastUpdated || ''
};
}
function toUser(point) {
const p = point.payload || {};
return { id: point.id, name: p.name || '—', email: p.email || '', role: p.role || 'unknown' };
}
// Card with a tinted icon header band.
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
return (
<Card sx={{
@@ -69,7 +41,7 @@ function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false
<Stack
direction="row" spacing={1.5} alignItems="center"
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%)`
}}
>
@@ -95,10 +67,10 @@ export default function Dashboard() {
const load = () => {
setLoading(true);
setError(null);
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)])
Promise.all([fetchClients(), fetchUsers()])
.then(([cs, us]) => {
setClients(cs.map(toClient));
setTeam(us.map(toUser));
setClients((cs || []).map(toClient));
setTeam((us || []).map(toUser));
})
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
.finally(() => setLoading(false));
@@ -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>}
<Grid container spacing={3}>
<Grid container spacing={2.5}>
<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="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 ? (
<EmptyState title="No data" />
) : (
<Stack spacing={2.25}>
<Stack spacing={2}>
{byType.map(([type, count], i) => (
<Box key={type}>
<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}>
{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} />
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<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 INITIAL_GENERAL = {
orgName: 'Doormile Logistics Pvt. Ltd.',
orgName: 'Doormile Technologies',
supportEmail: 'support@doormile.in',
contact: '+91 63749 46729',
timezone: TIMEZONES[0],
@@ -71,7 +71,7 @@ function Section({ icon: Icon, title, subtitle, color = 'primary', danger = fals
<Stack
direction="row" spacing={1.75} alignItems="center"
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%)`
}}
>
@@ -170,7 +170,7 @@ export default function Settings() {
{/* Sidebar */}
<Grid item xs={12} md={3}>
<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>
<Stack spacing={0.25} sx={{ mt: 0.5 }}>
{NAV.map((item, i) => {
@@ -182,7 +182,7 @@ export default function Settings() {
direction="row" spacing={1.5} alignItems="center"
onClick={() => setTab(i)}
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',
transition: 'background-color .15s',
'&:hover': { bgcolor: active ? 'primary.lighter' : 'grey.50' },
@@ -203,7 +203,7 @@ export default function Settings() {
</Stack>
</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}>
<Box sx={{ width: 38, height: 38, borderRadius: 2, bgcolor: 'primary.main', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<HelpOutlineRoundedIcon fontSize="small" />

View File

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

View File

@@ -0,0 +1,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,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
TablePagination, Typography, CircularProgress, Alert, Tooltip, Divider, useMediaQuery,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Grid
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
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 PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount';
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
import { fetchUsers, deleteUser } from '@/utils/apiClient';
import { toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
import UserFormDialog from './UserFormDialog';
// Map a raw Qdrant point from doormile_auth to a flat team-user row.
function toUser(point) {
const p = point.payload || {};
return {
id: point.id,
name: p.name || '—',
email: p.email || '',
phone: p.phone || '',
role: p.role || 'unknown',
pin: p.pin || ''
};
}
const titleCase = (s) => String(s || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
// Per-role accent colour + icon, used for the avatar badge and role chip.
const ROLE_META = {
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
@@ -98,7 +86,7 @@ function RoleCell({ role }) {
// Mobile presentation of a user row — a self-contained card instead of a wide table row.
function UserCard({ row, index, onEdit, onDelete }) {
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">
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<UserIdentity name={row.name} email={row.email} role={row.role} />
@@ -156,7 +144,7 @@ export default function TeamUsers() {
const load = () => {
setLoading(true);
setError(null);
fetchPoints(COLLECTIONS.teamUsers)
fetchUsers()
.then((points) => setUsers(points.map(toUser)))
.catch((e) => setError(e.message || 'Failed to load team users'))
.finally(() => setLoading(false));
@@ -178,6 +166,15 @@ export default function TeamUsers() {
return c;
}, [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(
() =>
users.filter((u) => {
@@ -196,7 +193,7 @@ export default function TeamUsers() {
const confirmDelete = async () => {
setDeleting(true);
try {
await deletePoint(COLLECTIONS.teamUsers, toDelete.id);
await deleteUser(toDelete.id);
setToDelete(null);
load();
} catch (e) {
@@ -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' }}>
<Box
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,
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>
<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
size="small" placeholder="Search by name, email, phone…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
sx={{ width: { xs: '100%', md: 300 } }}
@@ -248,7 +252,7 @@ export default function TeamUsers() {
<Typography variant="body2" color="text.secondary">
{filtered.length} {filtered.length === 1 ? 'user' : 'users'}
</Typography>
<Chip size="small" label="live · doormile_auth" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
<Chip size="small" label="live · PostgreSQL" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
</Stack>
)}
</Stack>
@@ -268,7 +272,7 @@ export default function TeamUsers() {
) : paged.length === 0 ? (
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
) : isMobile ? (
<Stack spacing={1.25} sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5} sx={{ p: 2 }}>
{paged.map((row, i) => (
<UserCard
key={row.id}
@@ -340,7 +344,7 @@ export default function TeamUsers() {
<DialogTitle>Delete user?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_auth collection. This cannot be undone.
This will permanently remove <strong>{toDelete?.name}</strong> from the PostgreSQL database. This cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>

View File

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

View File

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

View File

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