Finalize CRM transformation, mobile responsiveness, and Qdrant integration
This commit is contained in:
26
backedn.txt
Normal file
26
backedn.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
Here is the expanded architectural design base, updated specifically to handle a rapidly growing database with many collections beyond just Auth and Clients.
|
||||
|
||||
1. The UX Flow (Built for Scale)
|
||||
When you have dozens of collections, hardcoding every single view isn't scalable. The dashboard needs to act like a true "Command Center" with dynamic discovery:
|
||||
|
||||
Zone A: "Dedicated Modules" (The Custom Experiences)
|
||||
Purpose: Highly tailored, customized views for your most important business logic.
|
||||
Flow: The sidebar permanently pins Team Access (doormile_auth) and Field Operations (doormile_clients). These open the specialized Tables, Kanban boards, and GeoMaps we discussed earlier.
|
||||
Zone B: "The Data Explorer" (For the remaining dozens of collections)
|
||||
Purpose: A dynamic, auto-generating view to inspect any collection that exists on the server, even ones created yesterday.
|
||||
Flow: Admin scrolls down the sidebar to a "Data Lake" section -> Uses a mini search bar to find a specific collection by name -> Clicks it -> The app dynamically generates a generic table based on whatever payload data is inside it.
|
||||
2. The Design Components (The Base for Scale)
|
||||
To build this massive multi-collection system, you will add these components to the base:
|
||||
|
||||
🗂️ For the Sidebar / Navigation
|
||||
CollectionDiscovery Component: A scrollable list on the left menu that automatically asks Qdrant for a list of all collections and renders them.
|
||||
SidebarFilter Component: A tiny search box sitting right above the collection list so you can instantly filter out collection names (e.g., typing "survey" shows survey_2024, survey_2025, survey_archived).
|
||||
🧬 For the Generic / Unknown Collections (The Explorer)
|
||||
Since you won't build a custom UI for every single minor collection, you need components that adapt to any data:
|
||||
|
||||
AdaptiveDataGrid Component: A smart table that looks at the first vector it fetches from Qdrant, reads the JSON payload keys, and automatically creates the table columns on the fly. (If a collection has item_name and price, the table creates those two columns automatically).
|
||||
VectorInspector Modal: When you click a row in an unknown collection, this opens a sleek, dark-themed JSON code viewer. It shows the raw payload and the raw floating-point vector arrays perfectly formatted, similar to how a developer console looks.
|
||||
ClusterHealthWidget: A component at the very top of the dashboard that shows the sum of all vectors across all collections, total RAM usage, and server uptime.
|
||||
⚙️ The Updated Shared Components
|
||||
ViewToggle Component: A simple switch at the top right of the screen that lets an admin toggle any collection between "Card View" (visual) and "Table View" (dense data).
|
||||
GlobalSearch (Upgraded): The search bar now includes a dropdown filter to let you select which of the 50+ collections you actually want to search inside.
|
||||
@@ -12,7 +12,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800&family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Doormile Console</title>
|
||||
<title>Doormile CRM</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
32
src/App.jsx
32
src/App.jsx
@@ -5,6 +5,8 @@ import { Box, CircularProgress } from '@mui/material';
|
||||
import MainLayout from '@/layout/MainLayout';
|
||||
import MinimalLayout from '@/layout/MinimalLayout';
|
||||
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
|
||||
const load = (factory) => {
|
||||
const C = lazy(factory);
|
||||
return (
|
||||
@@ -24,39 +26,13 @@ export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Shell pages */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
|
||||
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
||||
|
||||
<Route path="/orders" element={load(() => import('@/pages/orders/OrdersList'))} />
|
||||
<Route path="/orders/create" element={load(() => import('@/pages/orders/CreateOrder'))} />
|
||||
<Route path="/orders/create-multiple" element={load(() => import('@/pages/orders/CreateMultipleOrders'))} />
|
||||
<Route path="/orders/assign" element={load(() => import('@/pages/orders/AssignOrders'))} />
|
||||
<Route path="/orders/:id" element={load(() => import('@/pages/orders/OrderDetails'))} />
|
||||
|
||||
<Route path="/deliveries" element={load(() => import('@/pages/Deliveries'))} />
|
||||
|
||||
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
|
||||
<Route path="/tenants/create" element={load(() => import('@/pages/tenants/CreateClient'))} />
|
||||
|
||||
<Route path="/customers" element={load(() => import('@/pages/customers/Customers'))} />
|
||||
<Route path="/customers/create" element={load(() => import('@/pages/customers/CreateCustomer'))} />
|
||||
<Route path="/team-users" element={load(() => import('@/pages/team/TeamUsers'))} />
|
||||
|
||||
<Route path="/pricing" element={load(() => import('@/pages/Pricing'))} />
|
||||
|
||||
<Route path="/riders" element={load(() => import('@/pages/riders/Riders'))} />
|
||||
<Route path="/riders/create" element={load(() => import('@/pages/riders/CreateRider'))} />
|
||||
<Route path="/riders/:id/edit" element={load(() => import('@/pages/riders/EditRider'))} />
|
||||
|
||||
<Route path="/reports/orders-summary" element={load(() => import('@/pages/reports/OrdersSummary'))} />
|
||||
<Route path="/reports/orders-details" element={load(() => import('@/pages/reports/OrdersDetails'))} />
|
||||
<Route path="/reports/riders-summary" element={load(() => import('@/pages/reports/RidersSummary'))} />
|
||||
<Route path="/reports/riders-logs" element={load(() => import('@/pages/reports/RidersLogs'))} />
|
||||
|
||||
<Route path="/invoice" element={load(() => import('@/pages/invoice/Invoices'))} />
|
||||
<Route path="/invoice/:id" element={load(() => import('@/pages/invoice/InvoicePreview'))} />
|
||||
|
||||
<Route path="/requests" element={load(() => import('@/pages/Requests'))} />
|
||||
<Route path="/profile" element={load(() => import('@/pages/Profile'))} />
|
||||
<Route path="/settings" element={load(() => import('@/pages/Settings'))} />
|
||||
</Route>
|
||||
|
||||
|
||||
BIN
src/assets/mid-mile-approach.jpg
Normal file
BIN
src/assets/mid-mile-approach.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
BIN
src/assets/premium_logistics_bg.png
Normal file
BIN
src/assets/premium_logistics_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 880 KiB |
11
src/components/AuthGuard.jsx
Normal file
11
src/components/AuthGuard.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
export default function AuthGuard({ children }) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children || <Outlet />;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Card, CardHeader, CardContent, Divider, Box } from '@mui/material';
|
||||
|
||||
// ==============================|| MAIN CARD (titled surface) ||============================== //
|
||||
|
||||
export default function MainCard({ title, action, children, divider = true, contentSx, sx, noPadding = false }) {
|
||||
return (
|
||||
<Card sx={sx}>
|
||||
{title && (
|
||||
<>
|
||||
<CardHeader title={title} action={action} />
|
||||
{divider && <Divider />}
|
||||
</>
|
||||
)}
|
||||
{noPadding ? <Box>{children}</Box> : <CardContent sx={contentSx}>{children}</CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import RoomIcon from '@mui/icons-material/Room';
|
||||
import TwoWheelerIcon from '@mui/icons-material/TwoWheeler';
|
||||
|
||||
// Stylised static "map" surface — pins + red route line. Swap for Leaflet/Google later.
|
||||
export default function MapPlaceholder({ height = 360, pins = [], showRoute = true, label = 'Live Tracking', riders = [] }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#EAEEF3',
|
||||
backgroundImage:
|
||||
'linear-gradient(rgba(0,0,0,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(0,0,0,0.04) 1px, transparent 1px)',
|
||||
backgroundSize: '32px 32px',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
>
|
||||
{/* faux roads */}
|
||||
<Box sx={{ position: 'absolute', top: '30%', left: 0, right: 0, height: 8, bgcolor: '#fff', opacity: 0.85 }} />
|
||||
<Box sx={{ position: 'absolute', top: 0, bottom: 0, left: '60%', width: 8, bgcolor: '#fff', opacity: 0.85 }} />
|
||||
<Box sx={{ position: 'absolute', top: '65%', left: 0, right: 0, height: 6, bgcolor: '#fff', opacity: 0.7 }} />
|
||||
|
||||
{showRoute && (
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
|
||||
<path d="M 18% 78% Q 45% 50% 78% 22%" fill="none" stroke="#C01227" strokeWidth="3" strokeDasharray="2 8" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{(pins.length ? pins : [
|
||||
{ x: '18%', y: '78%', label: 'Pickup', color: '#00A854' },
|
||||
{ x: '78%', y: '22%', label: 'Drop', color: '#C01227' }
|
||||
]).map((p, i) => (
|
||||
<Box key={i} sx={{ position: 'absolute', left: p.x, top: p.y, transform: 'translate(-50%, -100%)', textAlign: 'center' }}>
|
||||
<RoomIcon sx={{ color: p.color, fontSize: 34, filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.3))' }} />
|
||||
{p.label && (
|
||||
<Chip size="small" label={p.label} sx={{ bgcolor: '#fff', fontWeight: 600, mt: -0.5 }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{riders.map((r, i) => (
|
||||
<Box key={i} sx={{ position: 'absolute', left: r.x, top: r.y, transform: 'translate(-50%, -50%)' }}>
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: '50%', bgcolor: r.active ? '#C01227' : '#8C8C8C', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 6px rgba(0,0,0,0.3)' }}>
|
||||
<TwoWheelerIcon sx={{ color: '#fff', fontSize: 18 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Chip label={label} size="small" sx={{ position: 'absolute', top: 12, left: 12, bgcolor: '#fff', fontWeight: 600 }} />
|
||||
<Typography variant="caption" sx={{ position: 'absolute', bottom: 8, right: 12, color: 'grey.500' }}>
|
||||
Map data © Doormile demo
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,46 +4,74 @@ import ArrowDownwardRoundedIcon from '@mui/icons-material/ArrowDownwardRounded';
|
||||
|
||||
// ==============================|| STAT / KPI CARD ||============================== //
|
||||
|
||||
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption }) {
|
||||
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption, accent = false }) {
|
||||
const trendUp = typeof trend === 'number' ? trend >= 0 : null;
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.75, fontWeight: 700, color: 'grey.800' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.03)',
|
||||
border: '1px solid rgba(0,0,0,0.04)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 16px 32px rgba(0,0,0,0.06)' },
|
||||
background: (theme) => `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${theme.palette[color].lighter}15 100%)`
|
||||
}}
|
||||
>
|
||||
{Icon && (
|
||||
<Box sx={{ position: 'absolute', right: -15, bottom: -15, opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none' }}>
|
||||
<Icon sx={{ fontSize: 110 }} />
|
||||
</Box>
|
||||
)}
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5, position: 'relative', zIndex: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{Icon && (
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={{ bgcolor: `${color}.lighter`, color: `${color}.main`, width: 44, height: 44 }}
|
||||
<Box
|
||||
sx={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${color}.lighter`,
|
||||
color: `${color}.main`,
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Avatar>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ flexGrow: 1, position: 'relative', zIndex: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: 'grey.900', letterSpacing: '-0.5px', lineHeight: 1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{(trendUp !== null || caption) && (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 1.5 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 2, pt: 1.5, borderTop: '1px dashed rgba(0,0,0,0.06)', position: 'relative', zIndex: 2 }}>
|
||||
{trendUp !== null && (
|
||||
<>
|
||||
<Stack direction="row" spacing={0.25} alignItems="center" sx={{ bgcolor: trendUp ? 'success.lighter' : 'error.lighter', px: 0.75, py: 0.25, borderRadius: 1 }}>
|
||||
{trendUp ? (
|
||||
<ArrowUpwardRoundedIcon sx={{ fontSize: 16, color: 'success.main' }} />
|
||||
<ArrowUpwardRoundedIcon sx={{ fontSize: 14, color: 'success.dark' }} />
|
||||
) : (
|
||||
<ArrowDownwardRoundedIcon sx={{ fontSize: 16, color: 'error.main' }} />
|
||||
<ArrowDownwardRoundedIcon sx={{ fontSize: 14, color: 'error.dark' }} />
|
||||
)}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: trendUp ? 'success.main' : 'error.main' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: trendUp ? 'success.dark' : 'error.dark' }}>
|
||||
{Math.abs(trend)}%
|
||||
</Typography>
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
{caption && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,16 @@ const MAP = {
|
||||
skipped: { color: 'warning', label: 'Skipped' },
|
||||
failed: { color: 'error', label: 'Failed' },
|
||||
cancelled: { color: 'error', label: 'Cancelled' },
|
||||
// clients (doormile_clients)
|
||||
newclient: { color: 'info', label: 'New Client' },
|
||||
contacted: { color: 'warning', label: 'Contacted' },
|
||||
onboarded: { color: 'success', label: 'Onboarded' },
|
||||
lost: { color: 'error', label: 'Lost' },
|
||||
// team users (doormile_auth)
|
||||
admin: { color: 'primary', label: 'Admin' },
|
||||
rep: { color: 'info', label: 'Rep' },
|
||||
manager: { color: 'success', label: 'Manager' },
|
||||
support: { color: 'warning', label: 'Support' },
|
||||
// riders / tenants
|
||||
online: { color: 'success', label: 'Online' },
|
||||
offline: { color: 'default', label: 'Offline' },
|
||||
@@ -42,7 +52,8 @@ const TONE = {
|
||||
|
||||
export default function StatusChip({ status, size = 'small', label, sx }) {
|
||||
const key = String(status || '').toLowerCase().replace(/\s+/g, '-');
|
||||
const cfg = MAP[key] || { color: 'default', label: status };
|
||||
const humanized = String(status || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const cfg = MAP[key] || { color: 'default', label: humanized || status };
|
||||
const tone = TONE[cfg.color] || TONE.default;
|
||||
return (
|
||||
<Chip
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
|
||||
// Lightweight dependency-free area/line chart.
|
||||
// series: [{ name, color, data: number[] }], labels: string[]
|
||||
export default function AreaChart({ series = [], labels = [], height = 260 }) {
|
||||
const theme = useTheme();
|
||||
const W = 720;
|
||||
const H = height;
|
||||
const pad = { l: 40, r: 16, t: 16, b: 28 };
|
||||
const innerW = W - pad.l - pad.r;
|
||||
const innerH = H - pad.t - pad.b;
|
||||
|
||||
const all = series.flatMap((s) => s.data);
|
||||
const max = Math.max(...all, 1);
|
||||
const min = 0;
|
||||
const n = labels.length;
|
||||
|
||||
const x = (i) => pad.l + (n <= 1 ? 0 : (i / (n - 1)) * innerW);
|
||||
const y = (v) => pad.t + innerH - ((v - min) / (max - min)) * innerH;
|
||||
|
||||
const ticks = 4;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} preserveAspectRatio="xMidYMid meet">
|
||||
{Array.from({ length: ticks + 1 }).map((_, i) => {
|
||||
const gy = pad.t + (i / ticks) * innerH;
|
||||
const val = Math.round(max - (i / ticks) * (max - min));
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={pad.l} y1={gy} x2={W - pad.r} y2={gy} stroke={theme.palette.grey[200]} strokeWidth="1" />
|
||||
<text x={pad.l - 8} y={gy + 4} textAnchor="end" fontSize="11" fill={theme.palette.grey[500]}>
|
||||
{val}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{labels.map((lb, i) => (
|
||||
<text key={lb} x={x(i)} y={H - 8} textAnchor="middle" fontSize="11" fill={theme.palette.grey[500]}>
|
||||
{lb}
|
||||
</text>
|
||||
))}
|
||||
{series.map((s) => {
|
||||
const line = s.data.map((v, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(v)}`).join(' ');
|
||||
const area = `${line} L ${x(s.data.length - 1)} ${pad.t + innerH} L ${x(0)} ${pad.t + innerH} Z`;
|
||||
const gid = `grad-${s.name.replace(/\s/g, '')}`;
|
||||
return (
|
||||
<g key={s.name}>
|
||||
<defs>
|
||||
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{s.fill !== false && <path d={area} fill={`url(#${gid})`} />}
|
||||
<path d={line} fill="none" stroke={s.color} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{s.data.map((v, i) => (
|
||||
<circle key={i} cx={x(i)} cy={y(v)} r="3" fill="#fff" stroke={s.color} strokeWidth="2" />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
181
src/data/mock.js
181
src/data/mock.js
@@ -1,181 +0,0 @@
|
||||
// ==============================|| DOORMILE - MOCK DATA ||============================== //
|
||||
// Static demo data powering every screen.
|
||||
|
||||
export const locations = ['Bengaluru', 'Mumbai', 'Delhi NCR', 'Hyderabad', 'Chennai', 'Pune'];
|
||||
|
||||
export const tenantsList = [
|
||||
'Freshly Foods',
|
||||
'UrbanCart',
|
||||
'MediQuick Pharma',
|
||||
'BloomBox Florists',
|
||||
'TechNova Retail',
|
||||
'GreenLeaf Organics'
|
||||
];
|
||||
|
||||
export const orders = [
|
||||
{ id: 'DM-10241', tenant: 'Freshly Foods', location: 'Bengaluru', pickup: 'Koramangala Hub', drop: 'HSR Layout, Sec 2', customer: 'Riya Sharma', qty: 3, cod: 0, kms: 4.2, charges: 86, notes: 'Leave at door', status: 'delivered', date: '2026-06-04', payment: 'prepaid' },
|
||||
{ id: 'DM-10242', tenant: 'UrbanCart', location: 'Bengaluru', pickup: 'Indiranagar Store', drop: 'Whitefield, Phase 1', customer: 'Karthik Rao', qty: 1, cod: 1299, kms: 12.8, charges: 142, notes: 'Call on arrival', status: 'active', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10243', tenant: 'MediQuick Pharma', location: 'Mumbai', pickup: 'Andheri West', drop: 'Bandra East', customer: 'Neha Kulkarni', qty: 2, cod: 0, kms: 6.5, charges: 98, notes: '', status: 'picked', date: '2026-06-05', payment: 'prepaid' },
|
||||
{ id: 'DM-10244', tenant: 'BloomBox Florists', location: 'Delhi NCR', pickup: 'Connaught Place', drop: 'Saket, Block C', customer: 'Arjun Mehta', qty: 1, cod: 450, kms: 9.1, charges: 110, notes: 'Fragile', status: 'pending', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10245', tenant: 'Freshly Foods', location: 'Bengaluru', pickup: 'Koramangala Hub', drop: 'BTM Layout', customer: 'Sneha Iyer', qty: 4, cod: 0, kms: 3.4, charges: 74, notes: '', status: 'cancelled', date: '2026-06-03', payment: 'prepaid' },
|
||||
{ id: 'DM-10246', tenant: 'TechNova Retail', location: 'Hyderabad', pickup: 'Hitech City', drop: 'Gachibowli', customer: 'Vikram Reddy', qty: 1, cod: 4999, kms: 5.6, charges: 120, notes: 'ID required', status: 'created', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10247', tenant: 'GreenLeaf Organics', location: 'Pune', pickup: 'Koregaon Park', drop: 'Viman Nagar', customer: 'Pooja Desai', qty: 2, cod: 0, kms: 7.2, charges: 102, notes: '', status: 'delivered', date: '2026-06-04', payment: 'prepaid' },
|
||||
{ id: 'DM-10248', tenant: 'UrbanCart', location: 'Chennai', pickup: 'T. Nagar', drop: 'Adyar', customer: 'Suresh Kumar', qty: 1, cod: 799, kms: 8.3, charges: 108, notes: 'Weekend slot', status: 'pending', date: '2026-06-05', payment: 'cod' }
|
||||
];
|
||||
|
||||
export const orderTimeline = [
|
||||
{ label: 'Order Placed', time: '09:12 AM', done: true },
|
||||
{ label: 'Picked Up', time: '09:48 AM', done: true },
|
||||
{ label: 'In Transit', time: '10:05 AM', done: true },
|
||||
{ label: 'Delivered', time: '—', done: false }
|
||||
];
|
||||
|
||||
export const deliveries = orders.map((o, i) => ({
|
||||
...o,
|
||||
rider: ['Mohan Das', 'Imran Sheikh', 'Ravi Teja', 'Sandeep Roy', 'Faisal Khan'][i % 5],
|
||||
amount: o.charges,
|
||||
products: [
|
||||
{ name: 'Item A', description: 'Standard pack', qty: o.qty, cost: 40, price: 60, tax: 5, amount: 60 * o.qty }
|
||||
]
|
||||
}));
|
||||
|
||||
export const riders = [
|
||||
{ id: 'RDR-001', userId: 'U1043', name: 'Mohan Das', phone: '+91 98450 11223', address: 'Koramangala, Bengaluru', vehicle: 'Honda Activa', vehicleNo: 'KA-05-HJ-4521', shift: 'Morning', start: '08:00', end: '16:00', fare: 320, fuel: 110, status: 'online', deliveries: 14, rating: 4.8 },
|
||||
{ id: 'RDR-002', userId: 'U1044', name: 'Imran Sheikh', phone: '+91 98860 44781', address: 'Andheri, Mumbai', vehicle: 'TVS Jupiter', vehicleNo: 'MH-02-CD-9087', shift: 'Evening', start: '14:00', end: '22:00', fare: 280, fuel: 95, status: 'on-delivery', deliveries: 11, rating: 4.6 },
|
||||
{ id: 'RDR-003', userId: 'U1045', name: 'Ravi Teja', phone: '+91 99000 22119', address: 'Gachibowli, Hyderabad', vehicle: 'Hero Splendor', vehicleNo: 'TS-09-AB-3344', shift: 'Morning', start: '08:00', end: '16:00', fare: 300, fuel: 120, status: 'online', deliveries: 9, rating: 4.9 },
|
||||
{ id: 'RDR-004', userId: 'U1046', name: 'Sandeep Roy', phone: '+91 90080 55672', address: 'Saket, Delhi', vehicle: 'Bajaj Pulsar', vehicleNo: 'DL-3C-XY-1290', shift: 'Night', start: '22:00', end: '06:00', fare: 350, fuel: 130, status: 'offline', deliveries: 0, rating: 4.4 },
|
||||
{ id: 'RDR-005', userId: 'U1047', name: 'Faisal Khan', phone: '+91 97410 88123', address: 'Adyar, Chennai', vehicle: 'Honda Dio', vehicleNo: 'TN-07-PQ-6655', shift: 'Evening', start: '14:00', end: '22:00', fare: 290, fuel: 100, status: 'on-delivery', deliveries: 7, rating: 4.7 }
|
||||
];
|
||||
|
||||
export const riderLogs = [
|
||||
{ location: '12.9352, 77.6245', battery: '82%', charging: 'No', speed: '24 km/h', accuracy: '5 m', time: '10:42 AM', order: 'DM-10242', status: 'active' },
|
||||
{ location: '12.9298, 77.6848', battery: '80%', charging: 'No', speed: '0 km/h', accuracy: '8 m', time: '10:38 AM', order: 'DM-10242', status: 'arrived' },
|
||||
{ location: '12.9101, 77.6446', battery: '84%', charging: 'No', speed: '31 km/h', accuracy: '4 m', time: '10:25 AM', order: 'DM-10242', status: 'picked' }
|
||||
];
|
||||
|
||||
export const customers = [
|
||||
{ id: 1, name: 'Riya Sharma', phone: '+91 98111 22334', email: 'riya.sharma@mail.com', address: '24, 1st Cross, HSR Layout', location: 'Bengaluru', city: 'Bengaluru', state: 'Karnataka', postcode: '560102', totalOrders: 18, joined: '2025-03-12' },
|
||||
{ id: 2, name: 'Karthik Rao', phone: '+91 98222 33445', email: 'karthik.rao@mail.com', address: '8, Palm Meadows, Whitefield', location: 'Bengaluru', city: 'Bengaluru', state: 'Karnataka', postcode: '560066', totalOrders: 7, joined: '2025-07-21' },
|
||||
{ id: 3, name: 'Neha Kulkarni', phone: '+91 98333 44556', email: 'neha.k@mail.com', address: '102, Sea View, Bandra East', location: 'Mumbai', city: 'Mumbai', state: 'Maharashtra', postcode: '400051', totalOrders: 31, joined: '2024-11-02' },
|
||||
{ id: 4, name: 'Arjun Mehta', phone: '+91 98444 55667', email: 'arjun.m@mail.com', address: 'C-14, Saket', location: 'Delhi NCR', city: 'New Delhi', state: 'Delhi', postcode: '110017', totalOrders: 5, joined: '2025-09-18' },
|
||||
{ id: 5, name: 'Pooja Desai', phone: '+91 98555 66778', email: 'pooja.d@mail.com', address: '4, Lane 5, Koregaon Park', location: 'Pune', city: 'Pune', state: 'Maharashtra', postcode: '411001', totalOrders: 12, joined: '2025-01-30' }
|
||||
];
|
||||
|
||||
export const tenants = [
|
||||
{ id: 1, name: 'Freshly Foods', contact: 'Anil Gupta', phone: '+91 80451 22000', email: 'ops@freshlyfoods.in', address: 'Koramangala 4th Block', city: 'Bengaluru', postcode: '560034', lat: '12.9352', lng: '77.6245', status: 'active', volume: 1240 },
|
||||
{ id: 2, name: 'UrbanCart', contact: 'Meera Nair', phone: '+91 22480 11233', email: 'logistics@urbancart.com', address: 'Indiranagar 100ft Rd', city: 'Bengaluru', postcode: '560038', lat: '12.9719', lng: '77.6412', status: 'active', volume: 980 },
|
||||
{ id: 3, name: 'MediQuick Pharma', contact: 'Rohit Sen', phone: '+91 22556 99001', email: 'dispatch@mediquick.in', address: 'Andheri West', city: 'Mumbai', postcode: '400058', lat: '19.1351', lng: '72.8290', status: 'pending', volume: 410 },
|
||||
{ id: 4, name: 'BloomBox Florists', contact: 'Tina Roy', phone: '+91 11409 33221', email: 'hello@bloombox.in', address: 'Connaught Place', city: 'New Delhi', postcode: '110001', lat: '28.6315', lng: '77.2167', status: 'inactive', volume: 120 },
|
||||
{ id: 5, name: 'TechNova Retail', contact: 'Sameer Joshi', phone: '+91 40229 77654', email: 'ops@technova.in', address: 'Hitech City', city: 'Hyderabad', postcode: '500081', lat: '17.4435', lng: '78.3772', status: 'active', volume: 760 }
|
||||
];
|
||||
|
||||
export const tenantPricing = [
|
||||
{ date: '2025-04-01', slab: '0-5 km', basePrice: 40, minKms: 2, pricePerKm: 9, otherCharges: 0 },
|
||||
{ date: '2025-04-01', slab: '5-10 km', basePrice: 60, minKms: 5, pricePerKm: 8, otherCharges: 10 },
|
||||
{ date: '2025-04-01', slab: '10+ km', basePrice: 90, minKms: 10, pricePerKm: 7, otherCharges: 15 }
|
||||
];
|
||||
|
||||
export const pricing = [
|
||||
{ id: 1, location: 'Bengaluru', pricingId: 'PR-001', name: 'Standard', slab: '0-5 km', basePrice: 40, minKm: 2, pricePerKm: 9, maxKm: 5, minOrders: 1 },
|
||||
{ id: 2, location: 'Bengaluru', pricingId: 'PR-002', name: 'Express', slab: '5-10 km', basePrice: 65, minKm: 5, pricePerKm: 8, maxKm: 10, minOrders: 1 },
|
||||
{ id: 3, location: 'Mumbai', pricingId: 'PR-003', name: 'Standard', slab: '0-5 km', basePrice: 45, minKm: 2, pricePerKm: 10, maxKm: 5, minOrders: 1 },
|
||||
{ id: 4, location: 'Delhi NCR', pricingId: 'PR-004', name: 'Bulk', slab: '10+ km', basePrice: 95, minKm: 10, pricePerKm: 7, maxKm: 25, minOrders: 5 }
|
||||
];
|
||||
|
||||
export const invoices = [
|
||||
{ id: 1, invoiceId: 'INV-2026-0041', client: 'Freshly Foods', invoiceDate: '2026-06-01', dueDate: '2026-06-15', period: 'May 2026', count: 1240, amount: 106800, status: 'paid' },
|
||||
{ id: 2, invoiceId: 'INV-2026-0042', client: 'UrbanCart', invoiceDate: '2026-06-01', dueDate: '2026-06-15', period: 'May 2026', count: 980, amount: 84300, status: 'open' },
|
||||
{ id: 3, invoiceId: 'INV-2026-0043', client: 'MediQuick Pharma', invoiceDate: '2026-05-01', dueDate: '2026-05-15', period: 'Apr 2026', count: 410, amount: 35200, status: 'overdue' },
|
||||
{ id: 4, invoiceId: 'INV-2026-0044', client: 'TechNova Retail', invoiceDate: '2026-06-01', dueDate: '2026-06-20', period: 'May 2026', count: 760, amount: 65400, status: 'open' },
|
||||
{ id: 5, invoiceId: 'INV-2026-0045', client: 'GreenLeaf Organics', invoiceDate: '2026-05-01', dueDate: '2026-05-15', period: 'Apr 2026', count: 540, amount: 46900, status: 'paid' }
|
||||
];
|
||||
|
||||
export const invoiceLineItems = [
|
||||
{ particulars: 'Standard delivery (0-5 km)', unit: 'order', qty: 720, rate: 74, other: 0, amount: 53280 },
|
||||
{ particulars: 'Express delivery (5-10 km)', unit: 'order', qty: 380, rate: 108, other: 0, amount: 41040 },
|
||||
{ particulars: 'Bulk delivery (10+ km)', unit: 'order', qty: 140, rate: 142, other: 8, amount: 21000 },
|
||||
{ particulars: 'COD handling fee', unit: 'order', qty: 260, rate: 6, other: 0, amount: 1560 }
|
||||
];
|
||||
|
||||
export const requests = [
|
||||
{ id: 1, requestor: 'Freshly Foods', bank: 'HDFC Bank', ifsc: 'HDFC0001234', refNo: 'RQ-88121', amount: 24500, reason: 'Weekly settlement payout', contact: 'Anil Gupta', address: 'Koramangala 4th Block', city: 'Bengaluru', zip: '560034', accountNo: '5012 3344 7788', pricing: [{ category: 'Standard', skill: 'Bike', cost: 9 }, { category: 'Express', skill: 'Bike', cost: 12 }] },
|
||||
{ id: 2, requestor: 'UrbanCart', bank: 'ICICI Bank', ifsc: 'ICIC0004567', refNo: 'RQ-88122', amount: 18900, reason: 'Fuel reimbursement', contact: 'Meera Nair', address: 'Indiranagar', city: 'Bengaluru', zip: '560038', accountNo: '6022 1199 4455', pricing: [{ category: 'Standard', skill: 'Bike', cost: 8 }] },
|
||||
{ id: 3, requestor: 'MediQuick Pharma', bank: 'Axis Bank', ifsc: 'UTIB0007788', refNo: 'RQ-88123', amount: 9700, reason: 'Adjustment - April', contact: 'Rohit Sen', address: 'Andheri West', city: 'Mumbai', zip: '400058', accountNo: '7033 5566 1122', pricing: [{ category: 'Standard', skill: 'Bike', cost: 10 }] }
|
||||
];
|
||||
|
||||
// reports
|
||||
export const ordersSummary = tenants.map((t, i) => ({
|
||||
id: t.id,
|
||||
tenant: t.name,
|
||||
location: t.city,
|
||||
orders: { pending: [2, 1, 0, 3, 1][i], cancelled: [1, 0, 2, 0, 1][i], completed: [38, 27, 11, 4, 22][i] },
|
||||
deliveries: { pending: [1, 1, 0, 2, 0][i], cancelled: [0, 0, 1, 0, 1][i], completed: [38, 26, 10, 4, 22][i] },
|
||||
collection: [12400, 8800, 3200, 900, 6700][i],
|
||||
kms: [184, 142, 66, 28, 121][i],
|
||||
actualKms: [190, 148, 70, 30, 124][i],
|
||||
amount: [4280, 3120, 1420, 520, 2410][i],
|
||||
riders: [
|
||||
{ rider: 'Mohan Das', orders: 14, deliveries: 14, pending: 1, cancelled: 0, completed: 13, collection: 5200, kms: 64, actualKms: 66, charges: 1480 },
|
||||
{ rider: 'Imran Sheikh', orders: 11, deliveries: 10, pending: 0, cancelled: 1, completed: 9, collection: 4100, kms: 58, actualKms: 60, charges: 1280 }
|
||||
]
|
||||
}));
|
||||
|
||||
export const ordersDetailReport = orders.map((o, i) => ({
|
||||
id: o.id,
|
||||
client: o.tenant,
|
||||
pickup: o.pickup,
|
||||
drop: o.drop,
|
||||
status: o.status,
|
||||
assigned: '09:12',
|
||||
accepted: i % 5 === 3 ? '—' : '09:14',
|
||||
arrived: ['09:40', '—', '09:35', '—', '—', '—', '09:50', '—'][i],
|
||||
picked: ['09:48', '—', '09:42', '—', '—', '—', '09:58', '—'][i],
|
||||
active: ['10:05', '—', '10:00', '—', '—', '—', '10:12', '—'][i],
|
||||
delivered: o.status === 'delivered' ? '10:30' : '—',
|
||||
cancelled: o.status === 'cancelled' ? '09:20' : '—',
|
||||
notes: o.notes,
|
||||
kms: o.kms,
|
||||
charges: o.charges
|
||||
}));
|
||||
|
||||
export const ridersSummary = riders.map((r, i) => ({
|
||||
id: r.id,
|
||||
rider: r.name,
|
||||
orders: r.deliveries + [2, 1, 1, 0, 1][i],
|
||||
pending: [1, 0, 1, 0, 0][i],
|
||||
cancelled: [0, 1, 0, 0, 1][i],
|
||||
delivered: r.deliveries,
|
||||
kms: [64, 58, 47, 0, 39][i],
|
||||
actualKms: [66, 60, 49, 0, 41][i],
|
||||
amount: [1480, 1280, 1120, 0, 980][i],
|
||||
clients: [
|
||||
{ client: 'Freshly Foods', all: 8, pending: 1, completed: 7, cancelled: 0, kms: 32, actualKms: 33, amount: 740 },
|
||||
{ client: 'UrbanCart', all: 6, pending: 0, completed: 6, cancelled: 0, kms: 32, actualKms: 33, amount: 740 }
|
||||
]
|
||||
}));
|
||||
|
||||
export const ridersLive = riders.map((r, i) => ({
|
||||
...r,
|
||||
userid: r.userId,
|
||||
lastLog: ['10:42 AM', '10:39 AM', '10:40 AM', 'Yesterday', '10:35 AM'][i],
|
||||
active: r.status !== 'offline',
|
||||
lat: [12.9352, 19.1351, 17.4435, 28.6315, 13.0067][i],
|
||||
lng: [77.6245, 72.829, 78.3772, 77.2167, 80.2206][i]
|
||||
}));
|
||||
|
||||
// dashboard chart series (monthly orders)
|
||||
export const ordersTrend = [
|
||||
{ m: 'Jan', orders: 820, delivered: 760 },
|
||||
{ m: 'Feb', orders: 932, delivered: 880 },
|
||||
{ m: 'Mar', orders: 1010, delivered: 965 },
|
||||
{ m: 'Apr', orders: 1180, delivered: 1120 },
|
||||
{ m: 'May', orders: 1290, delivered: 1240 },
|
||||
{ m: 'Jun', orders: 1402, delivered: 1330 }
|
||||
];
|
||||
|
||||
export const statusBreakdown = [
|
||||
{ label: 'Delivered', value: 1240, color: '#00A854' },
|
||||
{ label: 'In Transit', value: 96, color: '#00A2AE' },
|
||||
{ label: 'Pending', value: 48, color: '#FFBF00' },
|
||||
{ label: 'Cancelled', value: 18, color: '#F04134' }
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
@@ -6,72 +6,78 @@ import {
|
||||
IconButton,
|
||||
Box,
|
||||
InputBase,
|
||||
Badge,
|
||||
Avatar,
|
||||
Typography,
|
||||
Stack,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Button,
|
||||
Stack,
|
||||
Popper,
|
||||
Paper,
|
||||
ClickAwayListener,
|
||||
CircularProgress,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
const INITIAL_NOTIFICATIONS = [
|
||||
{ id: 1, icon: Inventory2OutlinedIcon, title: 'New order #ORD-10482 placed', time: '2 min ago', to: '/orders', read: false },
|
||||
{ id: 2, icon: TwoWheelerOutlinedIcon, title: 'Rider Imran went online', time: '18 min ago', to: '/riders', read: false },
|
||||
{ id: 3, icon: PaymentsOutlinedIcon, title: 'Invoice INV-2041 marked paid', time: '1 hr ago', to: '/invoice', read: false },
|
||||
{ id: 4, icon: AssignmentOutlinedIcon, title: '3 new onboarding requests', time: '3 hrs ago', to: '/requests', read: true }
|
||||
];
|
||||
|
||||
const MESSAGES = [
|
||||
{ id: 1, name: 'Priya Nair', text: 'Can we reroute the MG Road batch?', time: '5 min ago', initials: 'PN' },
|
||||
{ id: 2, name: 'Imran Khan', text: 'Reached the warehouse, loading now.', time: '22 min ago', initials: 'IK' },
|
||||
{ id: 3, name: 'Acme Logistics', text: 'Please confirm the revised pricing.', time: '2 hrs ago', initials: 'AL' }
|
||||
];
|
||||
|
||||
export default function Header({ onToggle }) {
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [notifAnchor, setNotifAnchor] = useState(null);
|
||||
const [msgAnchor, setMsgAnchor] = useState(null);
|
||||
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
// Live client search for the top bar.
|
||||
const searchRef = useRef(null);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loadedClients, setLoadedClients] = useState(false);
|
||||
const [loadingClients, setLoadingClients] = useState(false);
|
||||
const [openResults, setOpenResults] = useState(false);
|
||||
|
||||
const openNotif = (e) => setNotifAnchor(e.currentTarget);
|
||||
const closeNotif = () => setNotifAnchor(null);
|
||||
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
const onNotifClick = (n) => {
|
||||
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
|
||||
closeNotif();
|
||||
navigate(n.to);
|
||||
const ensureClients = () => {
|
||||
if (loadedClients || loadingClients) return;
|
||||
setLoadingClients(true);
|
||||
fetchPoints(COLLECTIONS.clients)
|
||||
.then((points) => setClients(points.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.payload?.name || '—',
|
||||
city: p.payload?.city || '',
|
||||
businessType: p.payload?.businessType || '',
|
||||
phone: p.payload?.phone || ''
|
||||
}))))
|
||||
.catch(() => {})
|
||||
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
||||
};
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const results = q
|
||||
? clients.filter((c) => [c.name, c.city, c.businessType, c.phone].join(' ').toLowerCase().includes(q)).slice(0, 6)
|
||||
: [];
|
||||
|
||||
const onSearchChange = (e) => {
|
||||
setSearch(e.target.value);
|
||||
ensureClients();
|
||||
setOpenResults(true);
|
||||
};
|
||||
|
||||
const goToClients = (term) => {
|
||||
navigate(`/tenants?q=${encodeURIComponent(term)}`);
|
||||
setSearch('');
|
||||
setOpenResults(false);
|
||||
};
|
||||
|
||||
const submitSearch = (e) => {
|
||||
e.preventDefault();
|
||||
const q = search.trim();
|
||||
if (q) navigate(`/orders?q=${encodeURIComponent(q)}`);
|
||||
const term = search.trim();
|
||||
if (term) goToClients(term);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,55 +101,82 @@ export default function Header({ onToggle }) {
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Search — moved to the right */}
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={submitSearch}
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
bgcolor: alpha('#fff', 0.16),
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
'&:hover': { bgcolor: alpha('#fff', 0.22) },
|
||||
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<InputBase
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search orders, riders, customers…"
|
||||
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
||||
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
|
||||
/>
|
||||
</Box>
|
||||
{/* Search — live client lookup */}
|
||||
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
|
||||
<Box
|
||||
ref={searchRef}
|
||||
component="form"
|
||||
onSubmit={submitSearch}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: alpha('#fff', 0.16),
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
'&:hover': { bgcolor: alpha('#fff', 0.22) },
|
||||
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<InputBase
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
|
||||
placeholder="Search clients…"
|
||||
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
||||
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Tooltip title="Messages">
|
||||
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
|
||||
<Badge badgeContent={MESSAGES.length} color="warning">
|
||||
<ChatBubbleOutlineIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Notifications">
|
||||
<IconButton color="inherit" onClick={openNotif}>
|
||||
<Badge badgeContent={unread} color="warning">
|
||||
<NotificationsNoneIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Popper
|
||||
open={openResults && !!q}
|
||||
anchorEl={searchRef.current}
|
||||
placement="bottom-start"
|
||||
style={{ zIndex: 1400, width: searchRef.current?.offsetWidth }}
|
||||
>
|
||||
<Paper sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}>
|
||||
{loadingClients && results.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2.5 }}><CircularProgress size={20} /></Box>
|
||||
) : results.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">No clients match “{search.trim()}”.</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{results.map((c) => (
|
||||
<MenuItem key={c.id} onClick={() => goToClients(c.name)} sx={{ py: 1, gap: 1.25 }}>
|
||||
<UserAvatar name={c.name} size={30} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{[c.businessType, c.city].filter(Boolean).join(' · ') || c.phone}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => goToClients(search.trim())} sx={{ py: 1.25, color: 'primary.main', fontWeight: 600 }}>
|
||||
<SearchIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
See all results for “{search.trim()}”
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Popper>
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
|
||||
<Box
|
||||
onClick={(e) => setAccount(e.currentTarget)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 0.5, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: alpha('#fff', 0.14) } }}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>AD</Avatar>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>A</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||
Aman Deshmukh
|
||||
Admin
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
|
||||
Operations Admin
|
||||
@@ -151,87 +184,6 @@ export default function Header({ onToggle }) {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
<Menu
|
||||
anchorEl={notifAnchor}
|
||||
open={Boolean(notifAnchor)}
|
||||
onClose={closeNotif}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Notifications
|
||||
</Typography>
|
||||
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}>
|
||||
Mark all read
|
||||
</Button>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{notifications.length === 0 && (
|
||||
<MenuItem disabled>
|
||||
<ListItemText primary="No notifications" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{notifications.map((n) => {
|
||||
const Icon = n.icon;
|
||||
return (
|
||||
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
|
||||
<Icon fontSize="small" />
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={n.title}
|
||||
secondary={n.time}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
|
||||
secondaryTypographyProps={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { closeNotif(); navigate('/requests'); }} sx={{ justifyContent: 'center', color: 'primary.main', fontWeight: 600 }}>
|
||||
View all activity
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Messages dropdown */}
|
||||
<Menu
|
||||
anchorEl={msgAnchor}
|
||||
open={Boolean(msgAnchor)}
|
||||
onClose={() => setMsgAnchor(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
|
||||
Messages
|
||||
</Typography>
|
||||
<Divider />
|
||||
{MESSAGES.map((m) => (
|
||||
<MenuItem key={m.id} onClick={() => setMsgAnchor(null)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
||||
{m.initials}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={m.name}
|
||||
secondary={m.text}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 700 }}
|
||||
secondaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, mt: 0.5, flexShrink: 0 }}>
|
||||
{m.time}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Account dropdown */}
|
||||
<Menu
|
||||
anchorEl={account}
|
||||
@@ -239,12 +191,18 @@ export default function Header({ onToggle }) {
|
||||
onClose={() => setAccount(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
|
||||
PaperProps={{ sx: { mt: 1, minWidth: 220 } }}
|
||||
>
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/profile'); }}>
|
||||
<ListItemIcon><PersonOutlineIcon fontSize="small" /></ListItemIcon>
|
||||
View Profile
|
||||
</MenuItem>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Avatar sx={{ width: 38, height: 38, bgcolor: RED, color: '#fff', fontWeight: 700 }}>A</Avatar>
|
||||
<Box sx={{ lineHeight: 1.2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Admin</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Operations Admin</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/settings'); }}>
|
||||
<ListItemIcon><SettingsOutlinedIcon fontSize="small" /></ListItemIcon>
|
||||
Settings
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
{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 Console v1.0
|
||||
Doormile CRM v1.0
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,53 +1,23 @@
|
||||
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import MopedOutlinedIcon from '@mui/icons-material/MopedOutlined';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined';
|
||||
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import SummarizeOutlinedIcon from '@mui/icons-material/SummarizeOutlined';
|
||||
import FactCheckOutlinedIcon from '@mui/icons-material/FactCheckOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
|
||||
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
group: 'Operations',
|
||||
group: 'CRM',
|
||||
items: [
|
||||
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
|
||||
{ id: 'orders', title: 'Orders', url: '/orders', icon: Inventory2OutlinedIcon },
|
||||
{ id: 'deliveries', title: 'Deliveries', url: '/deliveries', icon: MopedOutlinedIcon }
|
||||
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon },
|
||||
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: GroupsOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Network',
|
||||
group: 'System',
|
||||
items: [
|
||||
{ id: 'tenants', title: 'Tenants', url: '/tenants', icon: ApartmentOutlinedIcon },
|
||||
{ id: 'pricing', title: 'Pricing', url: '/pricing', icon: PaymentsOutlinedIcon },
|
||||
{ id: 'customers', title: 'Customers', url: '/customers', icon: GroupsOutlinedIcon },
|
||||
{ id: 'riders', title: 'Riders', url: '/riders', icon: TwoWheelerOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Finance & Insights',
|
||||
items: [
|
||||
{
|
||||
id: 'reports',
|
||||
title: 'Reports',
|
||||
icon: BarChartOutlinedIcon,
|
||||
children: [
|
||||
{ id: 'orders-summary', title: 'Order Summary', url: '/reports/orders-summary', icon: SummarizeOutlinedIcon },
|
||||
{ id: 'orders-details', title: 'Order Details', url: '/reports/orders-details', icon: FactCheckOutlinedIcon },
|
||||
{ id: 'riders-summary', title: 'Riders Summary', url: '/reports/riders-summary', icon: TwoWheelerOutlinedIcon },
|
||||
{ id: 'riders-logs', title: 'Riders Logs', url: '/reports/riders-logs', icon: MapOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{ id: 'invoice', title: 'Invoice', url: '/invoice', icon: ReceiptLongOutlinedIcon },
|
||||
{ id: 'requests', title: 'Requests', url: '/requests', icon: AssignmentOutlinedIcon }
|
||||
{ id: 'settings', title: 'Settings', url: '/settings', icon: SettingsOutlinedIcon }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,124 +1,260 @@
|
||||
import { Grid, Card, CardContent, Stack, Typography, Box, Button, Divider, Table, TableBody, TableCell, TableHead, TableRow, Avatar, MenuItem, TextField } from '@mui/material';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Grid, Card, Box, Stack, Typography, Button, Divider, LinearProgress, CircularProgress, Alert,
|
||||
Table, TableBody, TableCell, TableHead, TableRow, TableContainer
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||
import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined';
|
||||
import DonutLargeOutlinedIcon from '@mui/icons-material/DonutLargeOutlined';
|
||||
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import AreaChart from '@/components/charts/AreaChart';
|
||||
import DonutChart from '@/components/charts/DonutChart';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { ordersTrend, statusBreakdown, orders, riders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
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();
|
||||
|
||||
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={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.03)',
|
||||
border: '1px solid rgba(0,0,0,0.04)'
|
||||
}}>
|
||||
<Stack
|
||||
direction="row" spacing={1.5} alignItems="center"
|
||||
sx={{
|
||||
px: 3, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${color}.lighter`, color: `${color}.main`, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.800', flexGrow: 1, letterSpacing: '-0.3px' }}>{title}</Typography>
|
||||
{action}
|
||||
</Stack>
|
||||
<Box sx={{ p: noPadding ? 0 : 3, flexGrow: 1 }}>{children}</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [team, setTeam] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)])
|
||||
.then(([cs, us]) => {
|
||||
setClients(cs.map(toClient));
|
||||
setTeam(us.map(toUser));
|
||||
})
|
||||
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: clients.length,
|
||||
newCount: clients.filter((c) => c.status === 'newClient').length,
|
||||
parcels: clients.reduce((s, c) => s + c.parcelVolume, 0),
|
||||
contracts: clients.reduce((s, c) => s + c.activeContracts, 0)
|
||||
}), [clients]);
|
||||
|
||||
const statusData = useMemo(() => {
|
||||
const m = {};
|
||||
clients.forEach((c) => { m[c.status] = (m[c.status] || 0) + 1; });
|
||||
return Object.entries(m).sort((a, b) => b[1] - a[1]).map(([status, value]) => ({ label: titleCase(status), value, color: statusColor(status) }));
|
||||
}, [clients]);
|
||||
|
||||
const byType = useMemo(() => {
|
||||
const m = {};
|
||||
clients.forEach((c) => { const t = c.businessType || 'other'; m[t] = (m[t] || 0) + 1; });
|
||||
return Object.entries(m).sort((a, b) => b[1] - a[1]);
|
||||
}, [clients]);
|
||||
const maxType = byType[0]?.[1] || 1;
|
||||
|
||||
const recent = useMemo(
|
||||
() => [...clients].sort((a, b) => String(b.lastUpdated).localeCompare(String(a.lastUpdated))).slice(0, 6),
|
||||
[clients]
|
||||
);
|
||||
|
||||
const today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}><CircularProgress /></Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
breadcrumbs={[{ label: 'Dashboard' }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<TextField select size="small" defaultValue="all" sx={{ minWidth: 150 }}>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
<MenuItem value="blr">Bengaluru</MenuItem>
|
||||
<MenuItem value="mum">Mumbai</MenuItem>
|
||||
</TextField>
|
||||
<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />}>Export</Button>
|
||||
</Stack>
|
||||
}
|
||||
action={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Total Orders" value="1,402" icon={Inventory2OutlinedIcon} trend={8.4} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Delivered" value="1,330" icon={LocalShippingOutlinedIcon} color="success" trend={6.1} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Active Riders" value="48" icon={TwoWheelerOutlinedIcon} color="info" trend={-2.3} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Revenue" value={inr(384200)} icon={CurrencyRupeeIcon} color="warning" trend={11.7} caption="vs last month" /></Grid>
|
||||
{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 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>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<MainCard
|
||||
title="Orders Overview"
|
||||
action={<Stack direction="row" spacing={2}><Legend color="#C01227" label="Orders" /><Legend color="#00A854" label="Delivered" /></Stack>}
|
||||
>
|
||||
<AreaChart
|
||||
labels={ordersTrend.map((d) => d.m)}
|
||||
series={[
|
||||
{ name: 'Orders', color: '#C01227', data: ordersTrend.map((d) => d.orders) },
|
||||
{ name: 'Delivered', color: '#00A854', data: ordersTrend.map((d) => d.delivered) }
|
||||
]}
|
||||
/>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Order Status">
|
||||
<Box sx={{ py: 2 }}>
|
||||
<DonutChart data={statusBreakdown} centerValue="1,402" centerLabel="Orders" />
|
||||
</Box>
|
||||
</MainCard>
|
||||
<Panel icon={HistoryOutlinedIcon} title="Recent Clients" color="primary" noPadding>
|
||||
{recent.length === 0 ? (
|
||||
<EmptyState title="No clients yet" caption="Add a client to see it here." />
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table sx={{ minWidth: 600 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell align="right">Parcels</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{recent.map((c) => (
|
||||
<TableRow key={c.id} hover>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={c.name} size={32} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{c.name}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="body2">{titleCase(c.businessType) || '—'}</Typography></TableCell>
|
||||
<TableCell><Typography variant="body2">{c.city || '—'}{c.businessState ? `, ${c.businessState}` : ''}</Typography></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{c.parcelVolume.toLocaleString('en-IN')}</TableCell>
|
||||
<TableCell><StatusChip status={c.status} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={7}>
|
||||
<MainCard title="Recent Orders" noPadding>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Order ID</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Route</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.slice(0, 6).map((o) => (
|
||||
<TableRow key={o.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{o.id}</TableCell>
|
||||
<TableCell>{o.customer}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">{o.pickup} → {o.drop}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={o.status} /></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MainCard>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Panel icon={DonutLargeOutlinedIcon} title="Clients by Status" color="primary">
|
||||
<Box sx={{ py: 1.5 }}>
|
||||
{statusData.length === 0
|
||||
? <EmptyState title="No data" />
|
||||
: <DonutChart data={statusData} centerValue={stats.total} centerLabel="Clients" />}
|
||||
</Box>
|
||||
</Panel>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={5}>
|
||||
<MainCard title="Top Riders Today">
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{riders.slice(0, 5).map((r, i) => (
|
||||
<Stack key={r.id} direction="row" spacing={2} alignItems="center" sx={{ py: 1.25 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ width: 18 }}>{i + 1}</Typography>
|
||||
<UserAvatar name={r.name} size={36} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2">{r.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{r.vehicle} · ⭐ {r.rating}</Typography>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Panel icon={CategoryOutlinedIcon} title="Clients by Business Type" color="primary">
|
||||
{byType.length === 0 ? (
|
||||
<EmptyState title="No data" />
|
||||
) : (
|
||||
<Stack spacing={2.25}>
|
||||
{byType.map(([type, count], i) => (
|
||||
<Box key={type}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.75 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: BAR_COLORS[i % BAR_COLORS.length] }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{titleCase(type)}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{count} · {Math.round((count / clients.length) * 100)}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(count / maxType) * 100}
|
||||
sx={{ height: 8, borderRadius: 4, bgcolor: 'grey.100', '& .MuiLinearProgress-bar': { borderRadius: 4, backgroundColor: BAR_COLORS[i % BAR_COLORS.length] } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="subtitle2">{r.deliveries}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">deliveries</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Panel icon={GroupsOutlinedIcon} title={`App Users · ${team.length}`} color="primary">
|
||||
{team.length === 0 ? (
|
||||
<EmptyState title="No team users" />
|
||||
) : (
|
||||
<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 }}>
|
||||
<UserAvatar name={u.name} size={36} />
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{u.email}</Typography>
|
||||
</Box>
|
||||
<StatusChip status={u.role} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ color, label }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: color }} />
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useState, useMemo, Fragment } from 'react';
|
||||
import {
|
||||
Grid, Card, Stack, Button, TextField, MenuItem, InputAdornment, Box, Tabs, Tab,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
Tooltip, TablePagination, Typography, Collapse, Menu
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import NotificationsActiveOutlinedIcon from '@mui/icons-material/NotificationsActiveOutlined';
|
||||
import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
|
||||
import EditLocationAltOutlinedIcon from '@mui/icons-material/EditLocationAltOutlined';
|
||||
import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { deliveries, locations, tenantsList, riders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'assigned', label: 'Assigned' },
|
||||
{ key: 'accepted', label: 'Accepted' },
|
||||
{ key: 'arrived', label: 'Arrived' },
|
||||
{ key: 'picked', label: 'Picked' },
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'skipped', label: 'Skipped' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' }
|
||||
];
|
||||
|
||||
function ProductTable({ products = [] }) {
|
||||
return (
|
||||
<Box sx={{ m: 2, borderRadius: 1, border: 1, borderColor: 'divider', overflow: 'hidden' }}>
|
||||
<Typography variant="subtitle2" sx={{ px: 2, py: 1.25, bgcolor: 'grey.50', color: 'grey.800' }}>
|
||||
Products
|
||||
</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>Product Name</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell align="center">Quantity</TableCell>
|
||||
<TableCell align="right">Cost</TableCell>
|
||||
<TableCell align="right">Price</TableCell>
|
||||
<TableCell align="right">Tax</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{products.map((p, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">{p.description}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">{p.qty}</TableCell>
|
||||
<TableCell align="right">{inr(p.cost)}</TableCell>
|
||||
<TableCell align="right">{inr(p.price)}</TableCell>
|
||||
<TableCell align="right">{p.tax}%</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryRow({ row, index }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [anchor, setAnchor] = useState(null);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TableRow hover sx={{ '& > *': { borderBottom: open ? 'unset' : undefined } }}>
|
||||
<TableCell padding="checkbox">
|
||||
<IconButton size="small" onClick={() => setOpen((o) => !o)}>
|
||||
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{row.tenant}</TableCell>
|
||||
<TableCell>{row.location}</TableCell>
|
||||
<TableCell>{row.pickup}</TableCell>
|
||||
<TableCell>{row.drop}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<UserAvatar name={row.rider} size={26} />
|
||||
<Typography variant="body2">{row.rider}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{row.notes || '—'}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.qty}</TableCell>
|
||||
<TableCell align="right">{row.cod ? inr(row.cod) : '—'}</TableCell>
|
||||
<TableCell align="right">{row.kms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(row.amount)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Actions">
|
||||
<IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)}>
|
||||
<MenuItem onClick={() => setAnchor(null)}>
|
||||
<NotificationsActiveOutlinedIcon fontSize="small" sx={{ mr: 1.5 }} /> Notify Rider
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => setAnchor(null)}>
|
||||
<SwapHorizOutlinedIcon fontSize="small" sx={{ mr: 1.5 }} /> Change Rider
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => setAnchor(null)}>
|
||||
<EditLocationAltOutlinedIcon fontSize="small" sx={{ mr: 1.5 }} /> Update Delivery Status
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => setAnchor(null)} sx={{ color: 'error.main' }}>
|
||||
<CancelOutlinedIcon fontSize="small" sx={{ mr: 1.5 }} /> Cancel Delivery
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<ProductTable products={row.products} />
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Deliveries() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [location, setLocation] = useState('all');
|
||||
const [rider, setRider] = useState('all');
|
||||
const [headerLocation, setHeaderLocation] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = {};
|
||||
TABS.forEach((t) => { c[t.key] = deliveries.filter((d) => d.status === t.key).length; });
|
||||
return c;
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
created: deliveries.length,
|
||||
pending: deliveries.filter((d) => d.status === 'pending').length,
|
||||
delivered: deliveries.filter((d) => d.status === 'delivered').length,
|
||||
cancelled: deliveries.filter((d) => d.status === 'cancelled').length
|
||||
}), []);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
deliveries.filter((d) => {
|
||||
const matchTab = d.status === tabKey;
|
||||
const matchTenant = tenant === 'all' || d.tenant === tenant;
|
||||
const matchLocation = location === 'all' || d.location === location;
|
||||
const matchRider = rider === 'all' || d.rider === rider;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[d.id, d.tenant, d.pickup, d.drop, d.rider, d.location].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchTenant && matchLocation && matchRider && matchSearch;
|
||||
}),
|
||||
[tabKey, tenant, location, rider, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Deliveries"
|
||||
breadcrumbs={[{ label: 'Deliveries' }]}
|
||||
action={
|
||||
<TextField
|
||||
select size="small" value={headerLocation} onChange={(e) => setHeaderLocation(e.target.value)}
|
||||
sx={{ minWidth: 180 }} label="Location"
|
||||
>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Created Orders" value={stats.created} icon={Inventory2OutlinedIcon} caption="100%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Pending Orders" value={stats.pending} icon={HourglassEmptyOutlinedIcon} color="warning" caption="25%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Delivered Orders" value={stats.delivered} icon={CheckCircleOutlineIcon} color="success" caption="25%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Cancelled Orders" value={stats.cancelled} icon={CancelOutlinedIcon} color="error" caption="12.5%" /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ mt: 1.5 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }} flexWrap="wrap" useFlexGap>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<TextField select size="small" value={tenant} onChange={(e) => { setTenant(e.target.value); setPage(0); }} sx={{ minWidth: 160 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={location} onChange={(e) => { setLocation(e.target.value); setPage(0); }} sx={{ minWidth: 150 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={rider} onChange={(e) => { setRider(e.target.value); setPage(0); }} sx={{ minWidth: 150 }} label="Rider">
|
||||
<MenuItem value="all">All Riders</MenuItem>
|
||||
{riders.map((r) => <MenuItem key={r.id} value={r.name}>{r.name}</MenuItem>)}
|
||||
</TextField>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<TextField
|
||||
size="small" placeholder="Search deliveries…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 220 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||
{TABS.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key]} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" />
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>Tenant</TableCell>
|
||||
<TableCell>Order Location</TableCell>
|
||||
<TableCell>Pickup</TableCell>
|
||||
<TableCell>Drop</TableCell>
|
||||
<TableCell>Rider</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
<TableCell align="center">Qty</TableCell>
|
||||
<TableCell align="right">COD</TableCell>
|
||||
<TableCell align="right">Kms</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
<TableCell align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} sx={{ border: 'none' }}>
|
||||
<EmptyState title="No deliveries found" caption="Try adjusting filters or switching tabs." />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paged.map((row, i) => <DeliveryRow key={row.id} row={row} index={page * rpp + i} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Autocomplete, TextField, Box,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Typography
|
||||
} from '@mui/material';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { pricing, locations } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const SLAB_COLORS = { '0-5 km': 'info', '5-10 km': 'warning', '10+ km': 'success' };
|
||||
const NAME_COLORS = { Standard: 'primary', Express: 'warning', Bulk: 'success' };
|
||||
|
||||
export default function Pricing() {
|
||||
const [location, setLocation] = useState(null);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (location ? pricing.filter((p) => p.location === location) : pricing),
|
||||
[location]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Pricing"
|
||||
breadcrumbs={[{ label: 'Pricing' }]}
|
||||
action={
|
||||
<Autocomplete
|
||||
size="small"
|
||||
options={locations}
|
||||
value={location}
|
||||
onChange={(_, v) => setLocation(v)}
|
||||
sx={{ minWidth: 240 }}
|
||||
renderInput={(params) => <TextField {...params} label="Filter by Location" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<MainCard noPadding>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState title="No Pricing List" caption="No pricing configured for the selected location." />
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Pricing Id</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Slab</TableCell>
|
||||
<TableCell align="right">Base Price</TableCell>
|
||||
<TableCell align="right">MinKm</TableCell>
|
||||
<TableCell align="right">Price/Km</TableCell>
|
||||
<TableCell align="right">MaxKm</TableCell>
|
||||
<TableCell align="center">Min Orders</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((p, i) => (
|
||||
<TableRow key={p.id} hover>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={p.location} color="info" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main' }}>{p.pricingId}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={p.name} color={NAME_COLORS[p.name] || 'primary'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={p.slab} color={SLAB_COLORS[p.slab] || 'default'} variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(p.basePrice)}</TableCell>
|
||||
<TableCell align="right">{p.minKm}</TableCell>
|
||||
<TableCell align="right">{inr(p.pricePerKm)}</TableCell>
|
||||
<TableCell align="right">{p.maxKm}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip size="small" label={p.minOrders} color="warning" variant="outlined" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</MainCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Grid, Card, CardContent, Stack, Typography, TextField } from '@mui/material';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
||||
const PROFILE = {
|
||||
userId: 'U1001',
|
||||
userName: 'Aarav Menon',
|
||||
appLocation: 'Bengaluru',
|
||||
authName: 'admin@doormile.in',
|
||||
contactNo: '+91 98450 11223',
|
||||
email: 'aarav.menon@doormile.in',
|
||||
address: 'No. 7, Brigade Road, MG Road Area',
|
||||
location: 'Bengaluru',
|
||||
city: 'Bengaluru',
|
||||
state: 'Karnataka',
|
||||
postcode: '560001',
|
||||
role: 'Operations Administrator'
|
||||
};
|
||||
|
||||
const ro = (value) => ({
|
||||
fullWidth: true,
|
||||
variant: 'standard',
|
||||
value,
|
||||
InputProps: { readOnly: true }
|
||||
});
|
||||
|
||||
export default function Profile() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="User Profile" breadcrumbs={[{ label: 'User Profile' }]} />
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2.5} alignItems="center">
|
||||
<UserAvatar name={PROFILE.userName} size={72} />
|
||||
<div>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'grey.800' }}>{PROFILE.userName}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{PROFILE.role}</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<MainCard title="Account Information">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6} md={4}><TextField label="User ID" {...ro(PROFILE.userId)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><TextField label="User Name" {...ro(PROFILE.userName)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><TextField label="App Location" {...ro(PROFILE.appLocation)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><TextField label="Auth Name" {...ro(PROFILE.authName)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><TextField label="Contact No" {...ro(PROFILE.contactNo)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><TextField label="E-Mail" {...ro(PROFILE.email)} /></Grid>
|
||||
<Grid item xs={12}><TextField label="Address" {...ro(PROFILE.address)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={3}><TextField label="Location" {...ro(PROFILE.location)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={3}><TextField label="City" {...ro(PROFILE.city)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={3}><TextField label="State" {...ro(PROFILE.state)} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={3}><TextField label="Postcode" {...ro(PROFILE.postcode)} /></Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card, Stack, Button, Box, Collapse, Tabs, Tab, Typography, Grid,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
TablePagination, Dialog, DialogTitle, DialogContent, DialogActions, TextField
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { requests } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
function RequestRow({ row, index }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow hover sx={{ '& > *': { borderBottom: open ? 'unset' : undefined } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{row.requestor}</TableCell>
|
||||
<TableCell>{row.bank}</TableCell>
|
||||
<TableCell>{row.ifsc}</TableCell>
|
||||
<TableCell>{row.refNo}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(row.amount)}</TableCell>
|
||||
<TableCell>{row.reason}</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton size="small" onClick={() => setOpen((o) => !o)}>
|
||||
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0, borderBottom: open ? 1 : 0, borderColor: 'divider' }} colSpan={8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ m: 2 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Client Details" />
|
||||
<Tab label="Client Pricing" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary">Contact Name</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.contact}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary">Address</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.address}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary">City</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.city}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary">Zip Code</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{row.zip}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700 } }}>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Skill</TableCell>
|
||||
<TableCell align="right">Cost/Hr</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{row.pricing.map((p, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>{p.category}</TableCell>
|
||||
<TableCell>{p.skill}</TableCell>
|
||||
<TableCell align="right">{inr(p.cost)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Requests() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const paged = requests.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Requests"
|
||||
breadcrumbs={[{ label: 'Requests' }]}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>
|
||||
Create Request
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Requestor</TableCell>
|
||||
<TableCell>Bank</TableCell>
|
||||
<TableCell>IFSC</TableCell>
|
||||
<TableCell>Ref No</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
<TableCell>Reason</TableCell>
|
||||
<TableCell align="center" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((row, idx) => (
|
||||
<RequestRow key={row.id} row={row} index={page * rpp + idx} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={requests.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25, 100]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Request</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2.5} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12} sm={6}><TextField label="Reference No" type="number" fullWidth /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField label="Requestor" fullWidth /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField label="Bank Name" fullWidth /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField label="Amount" type="number" fullWidth /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField label="Account No" type="number" fullWidth /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField label="IFSC Code" fullWidth /></Grid>
|
||||
<Grid item xs={12}><TextField label="Reason" fullWidth multiline minRows={3} /></Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setOpen(false)} color="inherit">Close</Button>
|
||||
<Button variant="contained" onClick={() => setOpen(false)}>Update</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
Stack,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
Snackbar,
|
||||
Alert
|
||||
Grid, Card, Box, Stack, TextField, MenuItem, Switch, Button, Typography, Divider,
|
||||
Snackbar, Alert, Chip, IconButton, InputAdornment, LinearProgress, Avatar
|
||||
} from '@mui/material';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
|
||||
import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
|
||||
import BusinessOutlinedIcon from '@mui/icons-material/BusinessOutlined';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
|
||||
const TIMEZONES = ['Asia/Kolkata (IST)', 'Asia/Dubai (GST)', 'UTC', 'America/New_York (EST)'];
|
||||
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
|
||||
|
||||
function TabPanel({ value, index, children }) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ pt: 1 }}>{children}</Box>;
|
||||
const INITIAL_GENERAL = {
|
||||
orgName: 'Doormile Logistics Pvt. Ltd.',
|
||||
supportEmail: 'support@doormile.in',
|
||||
contact: '+91 63749 46729',
|
||||
timezone: TIMEZONES[0],
|
||||
language: LANGUAGES[0]
|
||||
};
|
||||
const INITIAL_NOTIFY = {
|
||||
newClient: true, contractStatus: true, teamAccess: false, databaseSync: true, emailAlerts: true, smsAlerts: false
|
||||
};
|
||||
const INITIAL_SECURITY = { currentPassword: '', newPassword: '', confirmPassword: '', twoFactor: false };
|
||||
|
||||
const NAV = [
|
||||
{ icon: TuneOutlinedIcon, label: 'General', desc: 'Organisation profile' },
|
||||
{ icon: NotificationsNoneIcon, label: 'Notifications', desc: 'Alerts & channels' },
|
||||
{ icon: LockOutlinedIcon, label: 'Security', desc: 'Password & 2FA' }
|
||||
];
|
||||
|
||||
const NOTIFY_ROWS = [
|
||||
{ k: 'newClient', t: 'New client onboarded', d: 'Notify when a new client is added to the system' },
|
||||
{ k: 'contractStatus', t: 'Contract status', d: 'When a client contract becomes active or expires' },
|
||||
{ k: 'teamAccess', t: 'Team access', d: 'When a new team member is granted or revoked access' },
|
||||
{ k: 'databaseSync', t: 'Database sync', d: 'Alerts for vector database synchronization events' }
|
||||
];
|
||||
|
||||
const STRENGTH = [
|
||||
{ label: 'Too weak', color: 'error' },
|
||||
{ label: 'Weak', color: 'error' },
|
||||
{ label: 'Fair', color: 'warning' },
|
||||
{ label: 'Good', color: 'info' },
|
||||
{ label: 'Strong', color: 'success' }
|
||||
];
|
||||
const scorePassword = (pw) => {
|
||||
let s = 0;
|
||||
if (pw.length >= 8) s++;
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;
|
||||
if (/\d/.test(pw)) s++;
|
||||
if (/[^A-Za-z0-9]/.test(pw)) s++;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Section surface with a tinted icon header band.
|
||||
function Section({ icon: Icon, title, subtitle, color = 'primary', danger = false, children }) {
|
||||
return (
|
||||
<Card sx={danger ? { borderColor: 'error.light' } : undefined}>
|
||||
<Stack
|
||||
direction="row" spacing={1.75} alignItems="center"
|
||||
sx={{
|
||||
px: 3, py: 2.25, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, ${theme.palette.background.paper} 72%)`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${color}.lighter`, color: `${color}.main` }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.3 }}>{title}</Typography>
|
||||
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ px: 3 }}>{children}</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Two-column row: label + helper on the left, control on the right.
|
||||
function Row({ label, description, children, align = 'center' }) {
|
||||
return (
|
||||
<Grid
|
||||
container spacing={2} alignItems={align}
|
||||
sx={{ py: 2.5, borderRadius: 2, transition: 'background-color .15s', '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<Grid item xs={12} sm={5}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'grey.800' }}>{label}</Typography>
|
||||
{description && <Typography variant="caption" color="text.secondary">{description}</Typography>}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={7}>{children}</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
const rightAlign = { display: 'flex', justifyContent: { sm: 'flex-end' } };
|
||||
|
||||
function PasswordField({ label, value, onChange, autoComplete }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<TextField
|
||||
fullWidth size="small" type={show ? 'text' : 'password'} label={label} value={value} onChange={onChange} autoComplete={autoComplete}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [toast, setToast] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// General
|
||||
const [general, setGeneral] = useState({
|
||||
orgName: 'Doormile Logistics Pvt. Ltd.',
|
||||
supportEmail: 'support@doormile.in',
|
||||
contact: '+91 98450 11223',
|
||||
timezone: TIMEZONES[0],
|
||||
language: LANGUAGES[0]
|
||||
});
|
||||
const [general, setGeneral] = useState(INITIAL_GENERAL);
|
||||
const [notify, setNotify] = useState(INITIAL_NOTIFY);
|
||||
const [security, setSecurity] = useState(INITIAL_SECURITY);
|
||||
|
||||
// Notifications
|
||||
const [notify, setNotify] = useState({
|
||||
newOrders: true,
|
||||
riderStatus: true,
|
||||
invoicePaid: true,
|
||||
weeklyDigest: false,
|
||||
emailAlerts: true,
|
||||
smsAlerts: false
|
||||
});
|
||||
const setG = (k) => (e) => { setGeneral((p) => ({ ...p, [k]: e.target.value })); setDirty(true); };
|
||||
const setN = (k) => (e) => { setNotify((p) => ({ ...p, [k]: e.target.checked })); setDirty(true); };
|
||||
const setSText = (k) => (e) => { setSecurity((p) => ({ ...p, [k]: e.target.value })); setDirty(true); };
|
||||
|
||||
// Security
|
||||
const [security, setSecurity] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
twoFactor: false
|
||||
});
|
||||
const save = () => { setToast(true); setDirty(false); };
|
||||
const discard = () => {
|
||||
setGeneral(INITIAL_GENERAL);
|
||||
setNotify(INITIAL_NOTIFY);
|
||||
setSecurity(INITIAL_SECURITY);
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const setG = (k) => (e) => setGeneral((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setN = (k) => (e) => setNotify((p) => ({ ...p, [k]: e.target.checked }));
|
||||
const setS = (k) => (e) => setSecurity((p) => ({ ...p, [k]: e.target.value ?? e.target.checked }));
|
||||
|
||||
const save = () => setToast(true);
|
||||
const pwScore = scorePassword(security.newPassword);
|
||||
const pwMeta = STRENGTH[pwScore];
|
||||
const mismatch = security.confirmPassword && security.newPassword !== security.confirmPassword;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,120 +155,190 @@ export default function Settings() {
|
||||
title="Settings"
|
||||
breadcrumbs={[{ label: 'Settings' }]}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<SaveOutlinedIcon />} onClick={save}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
{dirty && <Chip size="small" label="Unsaved changes" sx={{ bgcolor: 'warning.lighter', color: 'warning.dark', fontWeight: 600 }} />}
|
||||
<Button variant="outlined" onClick={discard}>Discard</Button>
|
||||
<Button variant="contained" startIcon={<SaveOutlinedIcon />} onClick={save}>Save Changes</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
{/* Sidebar */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<MainCard noPadding>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
sx={{
|
||||
'& .MuiTab-root': { alignItems: 'flex-start', textTransform: 'none', minHeight: 52, fontWeight: 600 }
|
||||
}}
|
||||
>
|
||||
<Tab icon={<TuneOutlinedIcon fontSize="small" />} iconPosition="start" label="General" />
|
||||
<Tab icon={<NotificationsNoneIcon fontSize="small" />} iconPosition="start" label="Notifications" />
|
||||
<Tab icon={<LockOutlinedIcon fontSize="small" />} iconPosition="start" label="Security" />
|
||||
</Tabs>
|
||||
</MainCard>
|
||||
<Stack spacing={2.5} sx={{ position: { md: 'sticky' }, top: { md: 88 } }}>
|
||||
<Card sx={{ p: 1.5 }}>
|
||||
<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) => {
|
||||
const active = tab === i;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Stack
|
||||
key={item.label}
|
||||
direction="row" spacing={1.5} alignItems="center"
|
||||
onClick={() => setTab(i)}
|
||||
sx={{
|
||||
px: 1.5, py: 1.25, borderRadius: 2, cursor: 'pointer', position: 'relative',
|
||||
bgcolor: active ? 'primary.lighter' : 'transparent',
|
||||
transition: 'background-color .15s',
|
||||
'&:hover': { bgcolor: active ? 'primary.lighter' : 'grey.50' },
|
||||
'&::before': active ? { content: '""', position: 'absolute', left: 0, top: 9, bottom: 9, width: 3, borderRadius: 3, bgcolor: 'primary.main' } : {}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 34, height: 34, borderRadius: 1.5, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: active ? 'primary.main' : 'grey.100', color: active ? '#fff' : 'grey.600' }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: active ? 'primary.main' : 'grey.800' }}>{item.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{item.desc}</Typography>
|
||||
</Box>
|
||||
<ChevronRightIcon sx={{ fontSize: 18, color: active ? 'primary.main' : 'grey.300' }} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ p: 2.5, 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" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.dark' }}>Need a hand?</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'primary.dark', opacity: 0.85 }}>Our team is available 24/7 for operational support.</Typography>
|
||||
</Box>
|
||||
<Button size="small" variant="contained" sx={{ alignSelf: 'flex-start' }}>Contact support</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Content */}
|
||||
<Grid item xs={12} md={9}>
|
||||
{/* General */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
<MainCard title="Organisation">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Organisation Name" value={general.orgName} onChange={setG('orgName')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Support Email" value={general.supportEmail} onChange={setG('supportEmail')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Contact Number" value={general.contact} onChange={setG('contact')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Timezone" value={general.timezone} onChange={setG('timezone')}>
|
||||
{TIMEZONES.map((t) => (
|
||||
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Language" value={general.language} onChange={setG('language')}>
|
||||
{LANGUAGES.map((l) => (
|
||||
<MenuItem key={l} value={l}>{l}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
</TabPanel>
|
||||
|
||||
{/* Notifications */}
|
||||
<TabPanel value={tab} index={1}>
|
||||
<MainCard title="Notification Preferences">
|
||||
<Stack divider={<Divider flexItem />} spacing={0}>
|
||||
{[
|
||||
{ k: 'newOrders', t: 'New orders', d: 'Notify when a new order is placed' },
|
||||
{ k: 'riderStatus', t: 'Rider status', d: 'When a rider goes online or offline' },
|
||||
{ k: 'invoicePaid', t: 'Invoice paid', d: 'When a client settles an invoice' },
|
||||
{ k: 'weeklyDigest', t: 'Weekly digest', d: 'A summary of operations every Monday' }
|
||||
].map((row) => (
|
||||
<Stack key={row.k} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{row.t}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.d}</Typography>
|
||||
</Box>
|
||||
<Switch checked={notify[row.k]} onChange={setN(row.k)} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>Channels</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<FormControlLabel control={<Switch checked={notify.emailAlerts} onChange={setN('emailAlerts')} />} label="Email alerts" />
|
||||
<FormControlLabel control={<Switch checked={notify.smsAlerts} onChange={setN('smsAlerts')} />} label="SMS alerts" />
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</TabPanel>
|
||||
|
||||
{/* Security */}
|
||||
<TabPanel value={tab} index={2}>
|
||||
{tab === 0 && (
|
||||
<Stack spacing={2.5}>
|
||||
<MainCard title="Change Password">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth type="password" label="Current Password" value={security.currentPassword} onChange={setS('currentPassword')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} />
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth type="password" label="New Password" value={security.newPassword} onChange={setS('newPassword')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth type="password" label="Confirm New Password" value={security.confirmPassword} onChange={setS('confirmPassword')} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
<MainCard title="Two-Factor Authentication">
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Authenticator app</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Require a one-time code at sign-in for extra security.
|
||||
</Typography>
|
||||
{/* Organisation identity banner */}
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ sm: 'center' }}
|
||||
sx={{ p: 3, background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)` }}
|
||||
>
|
||||
<Avatar variant="rounded" sx={{ width: 64, height: 64, bgcolor: 'primary.main', color: '#fff' }}>
|
||||
<BusinessOutlinedIcon />
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800' }}>{general.orgName}</Typography>
|
||||
<Chip size="small" icon={<VerifiedOutlinedIcon sx={{ fontSize: 15, ml: 0.5 }} />} label="Verified" sx={{ fontWeight: 700, bgcolor: 'success.lighter', color: 'success.dark', '& .MuiChip-icon': { color: 'inherit' } }} />
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>{general.supportEmail} · {general.contact}</Typography>
|
||||
</Box>
|
||||
<Switch checked={security.twoFactor} onChange={(e) => setSecurity((p) => ({ ...p, twoFactor: e.target.checked }))} />
|
||||
<Button variant="outlined" size="small">Change logo</Button>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Card>
|
||||
|
||||
<Section icon={TuneOutlinedIcon} title="Organisation" subtitle="Profile and regional preferences" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
<Row label="Organisation name" description="Shown on invoices and exports">
|
||||
<TextField fullWidth size="small" value={general.orgName} onChange={setG('orgName')} />
|
||||
</Row>
|
||||
<Row label="Support email" description="Where customer replies are routed">
|
||||
<TextField fullWidth size="small" value={general.supportEmail} onChange={setG('supportEmail')} />
|
||||
</Row>
|
||||
<Row label="Contact number" description="Primary operations line">
|
||||
<TextField fullWidth size="small" value={general.contact} onChange={setG('contact')} />
|
||||
</Row>
|
||||
<Row label="Timezone" description="Used for schedules and reports">
|
||||
<TextField select fullWidth size="small" value={general.timezone} onChange={setG('timezone')}>
|
||||
{TIMEZONES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
</Row>
|
||||
<Row label="Language" description="Console display language">
|
||||
<TextField select fullWidth size="small" value={general.language} onChange={setG('language')}>
|
||||
{LANGUAGES.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
<Stack spacing={2.5}>
|
||||
<Section icon={NotificationsNoneIcon} title="Notification Preferences" subtitle="Choose what you get alerted about" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<Row key={row.k} label={row.t} description={row.d}>
|
||||
<Box sx={rightAlign}><Switch checked={notify[row.k]} onChange={setN(row.k)} /></Box>
|
||||
</Row>
|
||||
))}
|
||||
</Stack>
|
||||
</Section>
|
||||
<Section icon={CampaignOutlinedIcon} title="Delivery Channels" subtitle="How alerts reach your team" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
<Row label="Email alerts" description="Send notifications to the support inbox">
|
||||
<Box sx={rightAlign}><Switch checked={notify.emailAlerts} onChange={setN('emailAlerts')} /></Box>
|
||||
</Row>
|
||||
<Row label="SMS alerts" description="Send notifications to the registered mobile">
|
||||
<Box sx={rightAlign}><Switch checked={notify.smsAlerts} onChange={setN('smsAlerts')} /></Box>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === 2 && (
|
||||
<Stack spacing={2.5}>
|
||||
<Section icon={LockOutlinedIcon} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
<Row label="Current password" description="Enter your existing password" align="flex-start">
|
||||
<PasswordField label="Current password" value={security.currentPassword} onChange={setSText('currentPassword')} autoComplete="current-password" />
|
||||
</Row>
|
||||
<Row label="New password" description="Choose a strong, unique password" align="flex-start">
|
||||
<Box>
|
||||
<PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} autoComplete="new-password" />
|
||||
{security.newPassword && (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={(pwScore / 4) * 100} color={pwMeta.color} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: `${pwMeta.color}.main`, minWidth: 56 }}>{pwMeta.label}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Row>
|
||||
<Row label="Confirm new password" description="Re-enter the new password" align="flex-start">
|
||||
<Box>
|
||||
<PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} autoComplete="new-password" />
|
||||
{mismatch && <Typography variant="caption" color="error.main" sx={{ mt: 0.75, display: 'block' }}>Passwords do not match</Typography>}
|
||||
</Box>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<Section icon={ShieldOutlinedIcon} title="Two-Factor Authentication" subtitle="Add an extra layer of security to your account" color="primary">
|
||||
<Row label="Authenticator app" description="Require a one-time code at sign-in for extra security">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ justifyContent: { sm: 'flex-end' } }}>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<ShieldOutlinedIcon sx={{ fontSize: 15, ml: 0.5 }} />}
|
||||
label={security.twoFactor ? 'Enabled' : 'Disabled'}
|
||||
sx={{ fontWeight: 700, bgcolor: security.twoFactor ? 'success.lighter' : 'grey.100', color: security.twoFactor ? 'success.dark' : 'grey.600', '& .MuiChip-icon': { color: 'inherit' } }}
|
||||
/>
|
||||
<Switch checked={security.twoFactor} onChange={(e) => { setSecurity((p) => ({ ...p, twoFactor: e.target.checked })); setDirty(true); }} />
|
||||
</Stack>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section icon={WarningAmberRoundedIcon} title="Danger Zone" subtitle="Irreversible and high-impact actions" color="error" danger>
|
||||
<Row label="Sign out of all sessions" description="End every active session on all devices">
|
||||
<Box sx={rightAlign}>
|
||||
<Button variant="outlined" color="error" startIcon={<LogoutOutlinedIcon />} onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }}>Sign out everywhere</Button>
|
||||
</Box>
|
||||
</Row>
|
||||
</Section>
|
||||
</Stack>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -16,109 +16,139 @@ import {
|
||||
} from '@mui/material';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
import BoltIcon from '@mui/icons-material/Bolt';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import bgImage from '../../assets/mid-mile-approach.jpg';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [show, setShow] = useState(false);
|
||||
const [auth, setAuth] = useState('admin@doormile.in');
|
||||
const [auth, setAuth] = useState('');
|
||||
const [pwd, setPwd] = useState('');
|
||||
|
||||
return (
|
||||
<Grid container sx={{ minHeight: '100vh' }}>
|
||||
{/* Brand panel */}
|
||||
<Grid
|
||||
item
|
||||
md={6}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `url(${bgImage})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(192, 18, 39, 0.8) 100%)',
|
||||
zIndex: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'flex' },
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 6,
|
||||
color: '#fff',
|
||||
background: 'linear-gradient(150deg, #C01227 0%, #9E0E20 55%, #7E0B17 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
maxWidth: 440,
|
||||
p: { xs: 4, sm: 5 },
|
||||
m: 2,
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.5)',
|
||||
boxShadow: '0 24px 48px rgba(0,0,0,0.2)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', width: 420, height: 420, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', top: -120, right: -120 }} />
|
||||
<Box sx={{ position: 'absolute', width: 280, height: 280, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.06)', bottom: -80, left: -60 }} />
|
||||
<Logo onDark />
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Typography variant="h2" sx={{ color: '#fff', fontWeight: 800, lineHeight: 1.2 }}>
|
||||
Move every parcel,
|
||||
<br /> on time, every time.
|
||||
</Typography>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.85)', mt: 2, maxWidth: 420 }}>
|
||||
The command center for your last-mile operation — orders, riders, pricing and settlements in one corporate console.
|
||||
</Typography>
|
||||
<Stack spacing={1.5} sx={{ mt: 4 }}>
|
||||
{[
|
||||
{ icon: BoltIcon, t: 'AI-assisted route optimisation' },
|
||||
{ icon: LocalShippingOutlinedIcon, t: 'Real-time rider & delivery tracking' },
|
||||
{ icon: VerifiedOutlinedIcon, t: 'Automated client invoicing & payouts' }
|
||||
].map((f) => (
|
||||
<Stack key={f.t} direction="row" spacing={1.5} alignItems="center">
|
||||
<Box sx={{ width: 34, height: 34, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.16)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<f.icon fontSize="small" />
|
||||
</Box>
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.92)' }}>{f.t}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
© 2026 Doormile Logistics Pvt. Ltd.
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}><Logo /></Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, textAlign: 'center', letterSpacing: '-0.5px', color: '#1e293b' }}>Welcome back</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 1, mb: 4, textAlign: 'center', color: '#475569' }}>
|
||||
Sign in to your Doormile operations account.
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{/* Form panel */}
|
||||
<Grid item xs={12} md={6} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', p: { xs: 3, sm: 6 } }}>
|
||||
<Card sx={{ width: '100%', maxWidth: 420, p: { xs: 3, sm: 4.5 } }}>
|
||||
<Box sx={{ display: { xs: 'flex', md: 'none' }, mb: 2 }}><Logo /></Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700 }}>Welcome back</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 3 }}>
|
||||
Sign in to your Doormile operations account.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75 }}>Auth Name</Typography>
|
||||
<TextField fullWidth placeholder="Enter your auth name" value={auth} onChange={(e) => setAuth(e.target.value)} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75 }}>Password</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={pwd}
|
||||
onChange={(e) => setPwd(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<FormControlLabel control={<Checkbox defaultChecked size="small" />} label={<Typography variant="body2">Remember me</Typography>} />
|
||||
<Link href="#" underline="hover" variant="body2" color="primary">Forgot password?</Link>
|
||||
</Stack>
|
||||
<Button fullWidth size="large" variant="contained" onClick={() => navigate('/dashboard')}>
|
||||
Sign In
|
||||
</Button>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Auth Name</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Enter your auth name"
|
||||
value={auth}
|
||||
onChange={(e) => setAuth(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused': { bgcolor: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Password</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={pwd}
|
||||
onChange={(e) => setPwd(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.9)' },
|
||||
'&.Mui-focused': { bgcolor: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||
{show ? <VisibilityOff fontSize="small" sx={{ color: '#94a3b8' }} /> : <Visibility fontSize="small" sx={{ color: '#94a3b8' }} />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<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>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => { localStorage.setItem('auth_token', 'demo-session'); navigate('/dashboard'); }}
|
||||
sx={{
|
||||
mt: 2,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
fontSize: '1.05rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'none',
|
||||
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'
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<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.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid, Stack, Button, TextField, MenuItem, InputAdornment } from '@mui/material';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import { locations, tenantsList } from '@/data/mock';
|
||||
|
||||
const COUNTRY_CODES = ['+91', '+1', '+44', '+61', '+971'];
|
||||
|
||||
export default function CreateCustomer() {
|
||||
const navigate = useNavigate();
|
||||
const [code, setCode] = useState('+91');
|
||||
const [topLocation, setTopLocation] = useState('');
|
||||
const [client, setClient] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: '', phone: '', email: '', doorNo: '', address: '',
|
||||
location: '', city: '', state: '', postcode: '', landmark: ''
|
||||
});
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Customer"
|
||||
breadcrumbs={[{ label: 'Customers', to: '/customers' }, { label: 'Create Customer' }]}
|
||||
/>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2.5 }}>
|
||||
<TextField select size="small" label="Location" value={topLocation} onChange={(e) => setTopLocation(e.target.value)} sx={{ minWidth: 220 }}>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" label="Client" value={client} onChange={(e) => setClient(e.target.value)} sx={{ minWidth: 220 }}>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<MainCard title="Customer Details">
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Name" value={form.name} onChange={set('name')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Phone Number" value={form.phone} onChange={set('phone')}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<TextField
|
||||
select variant="standard" value={code} onChange={(e) => setCode(e.target.value)}
|
||||
InputProps={{ disableUnderline: true }} sx={{ minWidth: 56 }}
|
||||
>
|
||||
{COUNTRY_CODES.map((c) => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
||||
</TextField>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Email" value={form.email} onChange={set('email')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Door No" value={form.doorNo} onChange={set('doorNo')} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth size="small" label="Address" value={form.address} onChange={set('address')} multiline minRows={2} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth size="small" label="Location" value={form.location} onChange={set('location')}>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="City" value={form.city} onChange={set('city')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="State" value={form.state} onChange={set('state')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Post Code" value={form.postcode} onChange={set('postcode')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Landmark" value={form.landmark} onChange={set('landmark')} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1.5} sx={{ mt: 3 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/customers')}>Cancel</Button>
|
||||
<Button variant="contained" onClick={() => navigate('/customers')}>Create</Button>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Stack, Button, TextField, MenuItem, InputAdornment, Box,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
Tooltip, TablePagination, Typography,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Divider
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { customers, locations } from '@/data/mock';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '', phone: '', address: '', location: '', city: '', state: '',
|
||||
postcode: '', landmark: '', lat: '', lng: ''
|
||||
};
|
||||
|
||||
export default function Customers() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [location, setLocation] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [viewer, setViewer] = useState(null);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
customers.filter((c) => {
|
||||
const matchLocation = location === 'all' || c.location === location;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[c.name, c.phone, c.email, c.address, c.location, c.city]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
return matchLocation && matchSearch;
|
||||
}),
|
||||
[search, location]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
const openEdit = (c) => {
|
||||
setForm({
|
||||
name: c.name, phone: c.phone, address: c.address, location: c.location,
|
||||
city: c.city, state: c.state, postcode: c.postcode, landmark: c.landmark || '',
|
||||
lat: c.lat || '', lng: c.lng || ''
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openView = (c) => {
|
||||
setViewer(c);
|
||||
setViewOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Customers"
|
||||
breadcrumbs={[{ label: 'Customers' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ sm: 'center' }}>
|
||||
<TextField
|
||||
select size="small" value={location} onChange={(e) => { setLocation(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 160 }} label="Location"
|
||||
>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small" placeholder="Search customers…" value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }} sx={{ minWidth: 220 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/customers/create')}>
|
||||
Add Customer
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<MainCard noPadding>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState title="No Customers Found" caption="Try adjusting your search or location filter." />
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Contact</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((c, i) => (
|
||||
<TableRow key={c.id} hover>
|
||||
<TableCell>{page * rpp + i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={c.name} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{c.name}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{c.phone}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{c.email}</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 220 }}>
|
||||
<Typography variant="body2" noWrap>{c.address}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{c.city}, {c.state} {c.postcode}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{c.location}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="View"><IconButton size="small" onClick={() => openView(c)}><VisibilityOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={() => openEdit(c)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }}
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</MainCard>
|
||||
|
||||
<Dialog open={viewOpen} onClose={() => setViewOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Customer Details</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
{viewer && (
|
||||
<>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<UserAvatar name={viewer.name} size={56} />
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800' }}>{viewer.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{viewer.location}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Grid container spacing={2}>
|
||||
{[
|
||||
['Phone', viewer.phone],
|
||||
['Email', viewer.email],
|
||||
['Address', viewer.address],
|
||||
['City', viewer.city],
|
||||
['State', viewer.state],
|
||||
['Postcode', viewer.postcode],
|
||||
['Landmark', viewer.landmark || '—'],
|
||||
['Coordinates', viewer.lat && viewer.lng ? `${viewer.lat}, ${viewer.lng}` : '—']
|
||||
].map(([label, value]) => (
|
||||
<Grid item xs={12} sm={6} key={label}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{value}</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button variant="outlined" onClick={() => setViewOpen(false)}>Close</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => { setViewOpen(false); openEdit(viewer); }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Edit Customer</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent>
|
||||
<Grid container spacing={2.5} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Customer Name" value={form.name} onChange={set('name')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Contact Number" value={form.phone} onChange={set('phone')}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">+91</InputAdornment> }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth size="small" label="Address" value={form.address} onChange={set('address')} multiline minRows={2} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth size="small" label="Location" value={form.location} onChange={set('location')}>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="City" value={form.city} onChange={set('city')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="State" value={form.state} onChange={set('state')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Postcode" value={form.postcode} onChange={set('postcode')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Landmark" value={form.landmark} onChange={set('landmark')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} />
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Latitude" value={form.lat} onChange={set('lat')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth size="small" label="Longitude" value={form.lng} onChange={set('lng')} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button variant="outlined" onClick={() => setEditOpen(false)}>Close</Button>
|
||||
<Button variant="contained" onClick={() => setEditOpen(false)}>Update</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Box, Card, Stack, Button, Typography, Chip, Divider, IconButton,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Grid,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, TextField
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import Logo from '@/components/Logo';
|
||||
import { invoices, invoiceLineItems, tenants } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
export default function InvoicePreview() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
|
||||
const invoice = invoices.find((i) => String(i.id) === String(id)) || invoices[0];
|
||||
const tenant = tenants[0];
|
||||
|
||||
const subTotal = invoiceLineItems.reduce((a, l) => a + l.amount, 0);
|
||||
const discount = Math.round(subTotal * 0.05);
|
||||
const taxable = subTotal - discount;
|
||||
const tax = Math.round(taxable * 0.18);
|
||||
const grandTotal = taxable + tax;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Invoice Details"
|
||||
breadcrumbs={[{ label: 'Invoice', to: '/invoice' }, { label: 'Details' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems="center">
|
||||
<IconButton onClick={() => navigate('/invoice')} sx={{ border: 1, borderColor: 'grey.300' }}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Chip label={invoice.invoiceId} sx={{ bgcolor: 'warning.lighter', color: 'warning.dark', fontWeight: 600 }} />
|
||||
<Button variant="outlined" startIcon={<PaymentsOutlinedIcon />} onClick={() => setPayOpen(true)}>
|
||||
Update Payment
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<PrintOutlinedIcon />} onClick={() => window.print()}>
|
||||
Print
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Card sx={{ width: '100%', maxWidth: 860, p: { xs: 3, sm: 5 } }}>
|
||||
{/* Top band */}
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" spacing={3}>
|
||||
<Box>
|
||||
<Logo />
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, fontWeight: 700, color: 'grey.800' }}>From</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Doormile Logistics Pvt. Ltd.</Typography>
|
||||
<Typography variant="body2" color="text.secondary">No. 7, Brigade Road</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Bengaluru, Karnataka 560001</Typography>
|
||||
<Typography variant="body2" color="text.secondary">GSTIN: 29ABCDE1234F1Z5</Typography>
|
||||
<Typography variant="body2" color="text.secondary">billing@doormile.in</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: { sm: 'right' } }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: 'primary.main' }}>INVOICE</Typography>
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, fontWeight: 700, color: 'grey.800' }}>To</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{tenant.name}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{tenant.contact}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{tenant.address}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{tenant.city} {tenant.postcode}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{tenant.email}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Invoice meta */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="caption" color="text.secondary">Invoice No</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{invoice.invoiceId}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="caption" color="text.secondary">Date</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{invoice.invoiceDate}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="caption" color="text.secondary">Due Date</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{invoice.dueDate}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Typography variant="caption" color="text.secondary">Period</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{invoice.period}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Line items */}
|
||||
<TableContainer sx={{ mt: 3 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700 } }}>
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>Particulars</TableCell>
|
||||
<TableCell>Unit</TableCell>
|
||||
<TableCell align="center">Quantity</TableCell>
|
||||
<TableCell align="right">Rate</TableCell>
|
||||
<TableCell align="right">Other Charges</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invoiceLineItems.map((l, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{idx + 1}</TableCell>
|
||||
<TableCell>{l.particulars}</TableCell>
|
||||
<TableCell>{l.unit}</TableCell>
|
||||
<TableCell align="center">{l.qty}</TableCell>
|
||||
<TableCell align="right">{inr(l.rate)}</TableCell>
|
||||
<TableCell align="right">{inr(l.other)}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(l.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Totals */}
|
||||
<Stack alignItems="flex-end" sx={{ mt: 3 }}>
|
||||
<Box sx={{ width: { xs: '100%', sm: 320 } }}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ py: 0.75 }}>
|
||||
<Typography variant="body2" color="text.secondary">Sub Total</Typography>
|
||||
<Typography variant="body2">{inr(subTotal)}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ py: 0.75 }}>
|
||||
<Typography variant="body2" color="text.secondary">Discount (5%)</Typography>
|
||||
<Typography variant="body2">- {inr(discount)}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ py: 0.75 }}>
|
||||
<Typography variant="body2" color="text.secondary">Tax (18% GST)</Typography>
|
||||
<Typography variant="body2">{inr(tax)}</Typography>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ py: 0.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Grand Total</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: 'primary.main' }}>{inr(grandTotal)}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Notes + accent */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.800' }}>Notes & Terms</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Payment is due within 15 days of the invoice date. Please make payments via NEFT/RTGS to the
|
||||
registered account. A 1.5% monthly interest applies to overdue balances. This is a
|
||||
computer-generated invoice and does not require a signature.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} />
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Update Payment Dialog */}
|
||||
<Dialog open={payOpen} onClose={() => setPayOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Update Payment</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2.5} sx={{ mt: 1 }}>
|
||||
<TextField label="Reference No" type="number" fullWidth placeholder="Enter transaction reference" />
|
||||
<TextField label="Remarks" fullWidth multiline minRows={3} placeholder="Add a remark for this payment" />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setPayOpen(false)} color="inherit">Cancel</Button>
|
||||
<Button variant="contained" onClick={() => setPayOpen(false)}>Update</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, Stack, Button, Box, Tabs, Tab,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
Tooltip, TablePagination, Dialog, DialogTitle, DialogContent, DialogActions, Typography
|
||||
} from '@mui/material';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
|
||||
import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { invoices } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'open', label: 'Open' },
|
||||
{ key: 'overdue', label: 'Overdue' },
|
||||
{ key: 'paid', label: 'Paid' }
|
||||
];
|
||||
|
||||
export default function Invoices() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
const [dateOpen, setDateOpen] = useState(false);
|
||||
const [from, setFrom] = useState(dayjs().startOf('month'));
|
||||
const [to, setTo] = useState(dayjs());
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const sum = (s) => invoices.filter((i) => i.status === s).reduce((a, i) => a + i.amount, 0);
|
||||
return {
|
||||
billed: invoices.reduce((a, i) => a + i.amount, 0),
|
||||
paid: sum('paid'),
|
||||
open: sum('open'),
|
||||
overdue: sum('overdue')
|
||||
};
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
all: invoices.length,
|
||||
open: invoices.filter((i) => i.status === 'open').length,
|
||||
overdue: invoices.filter((i) => i.status === 'overdue').length,
|
||||
paid: invoices.filter((i) => i.status === 'paid').length
|
||||
};
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
const filtered = useMemo(
|
||||
() => invoices.filter((i) => (tabKey === 'all' ? true : i.status === tabKey)),
|
||||
[tabKey]
|
||||
);
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Invoice"
|
||||
breadcrumbs={[{ label: 'Invoice' }]}
|
||||
action={
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} onClick={() => setDateOpen(true)} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
{from.format('MMM DD')} – {to.format('MMM DD')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Total Billed" value={inr(totals.billed)} icon={ReceiptLongOutlinedIcon} caption="All invoices" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Paid" value={inr(totals.paid)} icon={CheckCircleOutlineIcon} color="success" caption={`${counts.paid} invoices`} /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Pending / Open" value={inr(totals.open)} icon={HourglassEmptyOutlinedIcon} color="warning" caption={`${counts.open} invoices`} /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Overdue" value={inr(totals.overdue)} icon={ErrorOutlineOutlinedIcon} color="error" caption={`${counts.overdue} invoices`} /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ mt: 1.5 }}>
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }}>
|
||||
{TABS.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key]} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Invoice Id</TableCell>
|
||||
<TableCell>Invoice Date</TableCell>
|
||||
<TableCell>Due Date</TableCell>
|
||||
<TableCell align="center">Count</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((row, idx) => (
|
||||
<TableRow key={row.id} hover>
|
||||
<TableCell>{page * rpp + idx + 1}</TableCell>
|
||||
<TableCell>{row.client}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main', cursor: 'pointer' }} onClick={() => navigate(`/invoice/${row.id}`)}>{row.invoiceId}</TableCell>
|
||||
<TableCell>{row.invoiceDate}</TableCell>
|
||||
<TableCell>{row.dueDate}</TableCell>
|
||||
<TableCell align="center">{row.count}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(row.amount)}</TableCell>
|
||||
<TableCell><StatusChip status={row.status} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Preview">
|
||||
<IconButton size="small" color="primary" onClick={() => navigate(`/invoice/${row.id}`)}>
|
||||
<VisibilityOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25, 100]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog open={dateOpen} onClose={() => setDateOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Filter by Date</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Select a date range to filter invoices.
|
||||
</Typography>
|
||||
<Stack spacing={2.5} sx={{ mt: 1 }}>
|
||||
<DatePicker label="From Date" value={from} onChange={(v) => v && setFrom(v)} slotProps={{ textField: { fullWidth: true } }} />
|
||||
<DatePicker label="To Date" value={to} onChange={(v) => v && setTo(v)} slotProps={{ textField: { fullWidth: true } }} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDateOpen(false)} color="inherit">Cancel</Button>
|
||||
<Button variant="contained" onClick={() => setDateOpen(false)}>Apply</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Stack, Button, IconButton, Typography, Box, TextField, MenuItem, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import AutorenewIcon from '@mui/icons-material/Autorenew';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||
import PersonAddAltOutlinedIcon from '@mui/icons-material/PersonAddAltOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { orders, riders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const ZONES = ['Zone A', 'Zone B', 'Zone C', 'Zone D'];
|
||||
const PROFIT = [42, 58, 36, 50, 28, 64];
|
||||
|
||||
// derive assignment rows from orders + riders mock
|
||||
const ROWS = orders.slice(0, 6).map((o, i) => ({
|
||||
...o,
|
||||
zone: ZONES[i % ZONES.length],
|
||||
rider: riders[i % riders.length].name,
|
||||
profit: PROFIT[i % PROFIT.length]
|
||||
}));
|
||||
|
||||
export default function AssignOrders() {
|
||||
const navigate = useNavigate();
|
||||
const [payment, setPayment] = useState('all');
|
||||
const [rider, setRider] = useState('auto');
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<IconButton onClick={() => navigate('/orders')} size="small"><ArrowBackIcon /></IconButton>
|
||||
<span>Assign Orders</span>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: 'Assign Orders' }]}
|
||||
action={
|
||||
<Button variant="outlined" startIcon={<AutorenewIcon />}>Re-Assign</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Orders" value={8} icon={Inventory2OutlinedIcon} caption="To assign" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Riders" value={5} icon={TwoWheelerOutlinedIcon} color="info" caption="Available" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Zones" value={4} icon={MapOutlinedIcon} color="warning" caption="Covered" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Kilometer" value={64} icon={RouteOutlinedIcon} color="success" caption="Total route" /></Grid>
|
||||
</Grid>
|
||||
|
||||
<MainCard title="Assignment Plan" noPadding sx={{ mt: 1.5 }}>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Zone</TableCell>
|
||||
<TableCell>Tenant</TableCell>
|
||||
<TableCell>Order Location</TableCell>
|
||||
<TableCell>Pickup</TableCell>
|
||||
<TableCell>Delivery</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
<TableCell>Rider</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell align="right">Profit</TableCell>
|
||||
<TableCell align="right">Charges</TableCell>
|
||||
<TableCell align="right">KMS</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ROWS.map((r) => (
|
||||
<TableRow key={r.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{r.id}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={r.zone} sx={{ bgcolor: 'primary.lighter', color: 'primary.dark', fontWeight: 600 }} />
|
||||
</TableCell>
|
||||
<TableCell>{r.tenant}</TableCell>
|
||||
<TableCell>{r.location}</TableCell>
|
||||
<TableCell>{r.pickup}</TableCell>
|
||||
<TableCell>{r.drop}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary" noWrap>{r.notes || '—'}</Typography></TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<UserAvatar name={r.rider} size={28} />
|
||||
<Typography variant="body2">{r.rider}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={r.payment} /></TableCell>
|
||||
<TableCell align="right" sx={{ color: 'success.main', fontWeight: 600 }}>{inr(r.profit)}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(r.charges)}</TableCell>
|
||||
<TableCell align="right">{r.kms}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</MainCard>
|
||||
|
||||
<Box sx={{ mt: 2.5 }}>
|
||||
<Grid container spacing={2.5} alignItems="center">
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField select fullWidth size="small" label="Choose Payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
|
||||
<MenuItem value="all">All Payments</MenuItem>
|
||||
<MenuItem value="prepaid">Prepaid</MenuItem>
|
||||
<MenuItem value="cod">COD</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField select fullWidth size="small" label="Choose Rider" value={rider} onChange={(e) => setRider(e.target.value)}>
|
||||
<MenuItem value="auto">Auto Assign</MenuItem>
|
||||
{riders.map((rd) => <MenuItem key={rd.id} value={rd.id}>{rd.name}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
||||
<Button variant="outlined" startIcon={<ArrowBackIcon />} onClick={() => navigate('/orders')}>Back</Button>
|
||||
<Button variant="contained" startIcon={<PersonAddAltOutlinedIcon />} onClick={() => navigate('/orders')}>
|
||||
Assign Orders
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, CardContent, Stack, Button, TextField, MenuItem, Typography, Divider, Box,
|
||||
RadioGroup, Radio, FormControlLabel, FormControl, FormLabel, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Tooltip,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, List, ListItem, ListItemButton,
|
||||
ListItemIcon, ListItemText, Checkbox, InputAdornment
|
||||
} from '@mui/material';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import UploadFileOutlinedIcon from '@mui/icons-material/UploadFileOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import PersonAddAltOutlinedIcon from '@mui/icons-material/PersonAddAltOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { locations, tenantsList, tenants, customers } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const TIME_SLOTS = ['09:00 - 11:00', '11:00 - 13:00', '13:00 - 15:00', '15:00 - 17:00', '17:00 - 19:00'];
|
||||
|
||||
// derive table rows from the customers mock
|
||||
const buildRows = () =>
|
||||
customers.slice(0, 4).map((c, i) => ({
|
||||
id: c.id,
|
||||
customer: c.name,
|
||||
address: c.address,
|
||||
qty: [2, 1, 3, 1][i],
|
||||
cash: [0, 1299, 0, 450][i],
|
||||
kms: [4.2, 12.8, 6.5, 9.1][i],
|
||||
charge: [86, 142, 98, 110][i]
|
||||
}));
|
||||
|
||||
export default function CreateMultipleOrders() {
|
||||
const navigate = useNavigate();
|
||||
const [date, setDate] = useState(dayjs());
|
||||
const [slot, setSlot] = useState('');
|
||||
const [dropMode, setDropMode] = useState('csv');
|
||||
const [rows, setRows] = useState(buildRows);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [picked, setPicked] = useState([]);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(acc, r) => ({
|
||||
qty: acc.qty + r.qty,
|
||||
cash: acc.cash + r.cash,
|
||||
kms: +(acc.kms + r.kms).toFixed(1),
|
||||
charge: acc.charge + r.charge
|
||||
}),
|
||||
{ qty: 0, cash: 0, kms: 0, charge: 0 }
|
||||
),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const removeRow = (id) => setRows((p) => p.filter((r) => r.id !== id));
|
||||
|
||||
const filteredCustomers = customers.filter(
|
||||
(c) => !search || `${c.name} ${c.location}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const toggle = (id) => setPicked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
|
||||
|
||||
const addSelected = () => {
|
||||
const existing = new Set(rows.map((r) => r.id));
|
||||
const additions = customers
|
||||
.filter((c) => picked.includes(c.id) && !existing.has(c.id))
|
||||
.map((c) => ({ id: c.id, customer: c.name, address: c.address, qty: 1, cash: 0, kms: 5, charge: 90 }));
|
||||
setRows((p) => [...p, ...additions]);
|
||||
setPicked([]);
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Multiple Orders"
|
||||
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: 'Create Multiple Orders' }]}
|
||||
/>
|
||||
|
||||
<Stack spacing={2.5}>
|
||||
<Card>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{/* top selects */}
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField select fullWidth label="App Location" defaultValue="">
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField select fullWidth label="Choose Client" defaultValue="">
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField select fullWidth label="Business Location" defaultValue="">
|
||||
{tenants.map((t) => <MenuItem key={t.id} value={t.id}>{t.address}, {t.city}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField fullWidth label="Pickup Location" value="Koramangala Hub, Bengaluru" InputProps={{ readOnly: true }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<DatePicker label="Delivery Date" value={date} onChange={setDate} slotProps={{ textField: { fullWidth: true } }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField select fullWidth label="Pickup Time Slot" value={slot} onChange={(e) => setSlot(e.target.value)}>
|
||||
{TIME_SLOTS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>Drop</Typography>
|
||||
<Divider sx={{ mt: 1, mb: 2 }} />
|
||||
<FormControl>
|
||||
<FormLabel sx={{ mb: 1, fontSize: 14 }}>Add drop points using</FormLabel>
|
||||
<RadioGroup row value={dropMode} onChange={(e) => setDropMode(e.target.value)}>
|
||||
<FormControlLabel value="csv" control={<Radio />} label="Excel / CSV" />
|
||||
<FormControlLabel value="selection" control={<Radio />} label="Selection" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{dropMode === 'csv' ? (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ sm: 'center' }}>
|
||||
<Button variant="contained" component="label" startIcon={<UploadFileOutlinedIcon />}>
|
||||
Upload CSV
|
||||
<input type="file" hidden accept=".csv,.xlsx" />
|
||||
</Button>
|
||||
<Chip
|
||||
label="orders_batch.csv"
|
||||
onDelete={() => {}}
|
||||
sx={{ bgcolor: 'primary.lighter', color: 'primary.dark', fontWeight: 600 }}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button variant="outlined" startIcon={<PersonAddAltOutlinedIcon />} onClick={() => setDialogOpen(true)}>
|
||||
Select Customers
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MainCard title="Drop Points" noPadding>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell align="center">Quantity</TableCell>
|
||||
<TableCell align="right">Cash Collect</TableCell>
|
||||
<TableCell align="right">Kms</TableCell>
|
||||
<TableCell align="right">Charge</TableCell>
|
||||
<TableCell align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((r, i) => (
|
||||
<TableRow key={r.id} hover>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={r.customer} size={32} />
|
||||
<Typography variant="body2">{r.customer}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="body2" color="text.secondary">{r.address}</Typography></TableCell>
|
||||
<TableCell align="center">{r.qty}</TableCell>
|
||||
<TableCell align="right">{r.cash ? inr(r.cash) : '—'}</TableCell>
|
||||
<TableCell align="right">{r.kms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(r.charge)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Remove">
|
||||
<IconButton size="small" color="error" onClick={() => removeRow(r.id)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow sx={{ '& td': { fontWeight: 700, borderTop: 2, borderColor: 'divider' } }}>
|
||||
<TableCell colSpan={3}>Total</TableCell>
|
||||
<TableCell align="center">{totals.qty}</TableCell>
|
||||
<TableCell align="right">{inr(totals.cash)}</TableCell>
|
||||
<TableCell align="right">{totals.kms}</TableCell>
|
||||
<TableCell align="right">{inr(totals.charge)}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</MainCard>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<TextField fullWidth label="Notes" placeholder="Notes for this batch of orders" multiline minRows={3} />
|
||||
<Stack direction="row" justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/orders')}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
{/* Customer selection dialog */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Select Customers</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search customers…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ mb: 1.5 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<List disablePadding>
|
||||
{filteredCustomers.map((c) => (
|
||||
<ListItem key={c.id} disablePadding>
|
||||
<ListItemButton onClick={() => toggle(c.id)}>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Checkbox edge="start" checked={picked.includes(c.id)} tabIndex={-1} disableRipple />
|
||||
</ListItemIcon>
|
||||
<UserAvatar name={c.name} size={32} sx={{ mr: 1.5 }} />
|
||||
<ListItemText primary={c.name} secondary={`${c.address} · ${c.location}`} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="outlined" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={addSelected} disabled={!picked.length}>
|
||||
Add Selected{picked.length ? ` (${picked.length})` : ''}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, CardContent, Stack, Button, TextField, MenuItem, Typography, Divider,
|
||||
Autocomplete, FormControlLabel, Checkbox, Box
|
||||
} from '@mui/material';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { locations, tenantsList, tenants } from '@/data/mock';
|
||||
|
||||
const TIME_SLOTS = ['09:00 - 11:00', '11:00 - 13:00', '13:00 - 15:00', '15:00 - 17:00', '17:00 - 19:00'];
|
||||
|
||||
const SectionTitle = ({ children }) => (
|
||||
<>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>{children}</Typography>
|
||||
<Divider sx={{ mt: 1, mb: 2.5 }} />
|
||||
</>
|
||||
);
|
||||
|
||||
function AddressFields({ saveForLater, onSaveForLater }) {
|
||||
return (
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Contact Name" placeholder="Enter contact name" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Contact Number" placeholder="+91 ..." />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Address" placeholder="Enter full address" multiline minRows={2} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Door No / Street" placeholder="e.g. 24, 1st Cross" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth label="Location" defaultValue="">
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Postcode" placeholder="e.g. 560102" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Landmark" placeholder="Nearby landmark" />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={saveForLater} onChange={(e) => onSaveForLater(e.target.checked)} />}
|
||||
label="Save for later"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateOrder() {
|
||||
const navigate = useNavigate();
|
||||
const [deliveryDate, setDeliveryDate] = useState(dayjs());
|
||||
const [slot, setSlot] = useState('');
|
||||
const [savePickup, setSavePickup] = useState(false);
|
||||
const [saveDrop, setSaveDrop] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Order"
|
||||
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: 'Create Order' }]}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{/* Top row */}
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Autocomplete
|
||||
options={locations}
|
||||
renderInput={(params) => <TextField {...params} label="Location" placeholder="Select location" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Autocomplete
|
||||
options={tenantsList}
|
||||
renderInput={(params) => <TextField {...params} label="Client" placeholder="Select client" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Autocomplete
|
||||
options={tenants.map((t) => `${t.name} — ${t.address}`)}
|
||||
renderInput={(params) => <TextField {...params} label="Business Location" placeholder="Select business location" />}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle>Pickup Details</SectionTitle>
|
||||
<AddressFields saveForLater={savePickup} onSaveForLater={setSavePickup} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle>Drop Details</SectionTitle>
|
||||
<AddressFields saveForLater={saveDrop} onSaveForLater={setSaveDrop} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle>Schedule</SectionTitle>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<DatePicker
|
||||
label="Delivery Date"
|
||||
value={deliveryDate}
|
||||
onChange={setDeliveryDate}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth label="Pickup Time Slot" value={slot} onChange={(e) => setSlot(e.target.value)}>
|
||||
{TIME_SLOTS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Notes" placeholder="Special instructions for this order" multiline minRows={3} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mt: 3 }} />
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/orders')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/orders')}>
|
||||
Create Order
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, CardContent, Stack, Typography, Box, Button, Divider, IconButton, Avatar,
|
||||
Step, Stepper, StepLabel, StepConnector, stepConnectorClasses, styled, Chip
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { orders, orderTimeline, deliveries } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const RedConnector = styled(StepConnector)(({ theme }) => ({
|
||||
[`& .${stepConnectorClasses.line}`]: { borderColor: theme.palette.grey[300], borderLeftWidth: 2, minHeight: 28 },
|
||||
[`&.${stepConnectorClasses.active} .${stepConnectorClasses.line}, &.${stepConnectorClasses.completed} .${stepConnectorClasses.line}`]:
|
||||
{ borderColor: theme.palette.primary.main }
|
||||
}));
|
||||
|
||||
function Dot({ active }) {
|
||||
return (
|
||||
<Box sx={{ width: 26, height: 26, borderRadius: '50%', bgcolor: active ? 'primary.main' : 'grey.300', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{active ? <CheckIcon sx={{ fontSize: 16 }} /> : <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#fff' }} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderDetails() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const order = orders.find((o) => o.id === id) || orders[1];
|
||||
const delivery = deliveries.find((d) => d.id === order.id) || deliveries[1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<IconButton onClick={() => navigate('/orders')} size="small"><ArrowBackIcon /></IconButton>
|
||||
<span>Order Details</span>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: order.id }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Button variant="outlined" color="error">Cancel Order</Button>
|
||||
<Button variant="contained" startIcon={<EditOutlinedIcon />}>Edit Order</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
{/* Left column */}
|
||||
<Grid item xs={12} md={5} lg={4}>
|
||||
<Stack spacing={2.5}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Order ID</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>{order.id}</Typography>
|
||||
</Box>
|
||||
<StatusChip status={order.status} size="medium" />
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Stack spacing={1.25}>
|
||||
<Row label="Created" value={order.date} />
|
||||
<Row label="Tenant" value={order.tenant} />
|
||||
<Row label="Payment" value={<StatusChip status={order.payment} />} />
|
||||
<Row label="Distance" value={`${order.kms} km`} />
|
||||
<Row label="Amount" value={<Typography variant="h5" color="primary.main">{inr(order.charges)}</Typography>} />
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MainCard title="Customer">
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<UserAvatar name={order.customer} size={48} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1">{order.customer}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Recipient</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack spacing={1.25}>
|
||||
<IconRow icon={PhoneOutlinedIcon} text="+91 98765 43210" />
|
||||
<IconRow icon={LocationOnOutlinedIcon} text={`${order.drop}, ${order.location}`} />
|
||||
</Stack>
|
||||
</MainCard>
|
||||
|
||||
<MainCard title="Delivery Timeline">
|
||||
<Stepper orientation="vertical" connector={<RedConnector />} sx={{ ml: 0.5 }}>
|
||||
{orderTimeline.map((s) => (
|
||||
<Step key={s.label} active completed={s.done}>
|
||||
<StepLabel StepIconComponent={() => <Dot active={s.done} />}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: s.done ? 600 : 500, color: s.done ? 'text.primary' : 'text.secondary' }}>{s.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{s.time}</Typography>
|
||||
</Stack>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</MainCard>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right column */}
|
||||
<Grid item xs={12} md={7} lg={8}>
|
||||
<Stack spacing={2.5}>
|
||||
<MainCard title="Live Tracking" noPadding>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<MapPlaceholder height={380} label="In Transit" />
|
||||
<Stack direction="row" spacing={3} sx={{ mt: 2 }}>
|
||||
<RouteEnd color="#00A854" title="Pickup" text={order.pickup} />
|
||||
<RouteEnd color="#C01227" title="Drop" text={order.drop} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</MainCard>
|
||||
|
||||
<MainCard title="Assigned Rider">
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }} justifyContent="space-between">
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<UserAvatar name={delivery.rider} size={56} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1">{delivery.rider}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Rider · +91 98450 11223</Typography>
|
||||
<Box sx={{ mt: 0.5 }}><Chip size="small" label="On the way" sx={{ bgcolor: 'info.lighter', color: 'info.dark' }} /></Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="contained" startIcon={<CallOutlinedIcon />}>Call Rider</Button>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ label, value }) => (
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
{typeof value === 'string' ? <Typography variant="subtitle2">{value}</Typography> : value}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const IconRow = ({ icon: Icon, text }) => (
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<Icon sx={{ fontSize: 18, color: 'grey.500' }} />
|
||||
<Typography variant="body2">{text}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const RouteEnd = ({ color, title, text }) => (
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: color, mt: 0.5 }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">{title}</Typography>
|
||||
<Typography variant="subtitle2">{text}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, Stack, Button, TextField, MenuItem, InputAdornment, Box, Tabs, Tab,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Checkbox, IconButton,
|
||||
Tooltip, TablePagination, Typography, SpeedDial, SpeedDialAction, Chip
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import PostAddOutlinedIcon from '@mui/icons-material/PostAddOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import PersonAddAltOutlinedIcon from '@mui/icons-material/PersonAddAltOutlined';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { orders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'created', label: 'Created' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' }
|
||||
];
|
||||
|
||||
export default function OrdersList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState(searchParams.get('q') || '');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
orders.filter((o) => {
|
||||
const matchTab = tabKey === 'created' ? true : o.status === tabKey;
|
||||
const matchTenant = tenant === 'all' || o.tenant === tenant;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[o.id, o.customer, o.pickup, o.drop, o.tenant].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchTenant && matchSearch;
|
||||
}),
|
||||
[tabKey, tenant, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
const toggle = (id) => setSelected((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
|
||||
const counts = {
|
||||
created: orders.length,
|
||||
pending: orders.filter((o) => o.status === 'pending').length,
|
||||
delivered: orders.filter((o) => o.status === 'delivered').length,
|
||||
cancelled: orders.filter((o) => o.status === 'cancelled').length
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
breadcrumbs={[{ label: 'Orders' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<PostAddOutlinedIcon />} onClick={() => navigate('/orders/create-multiple')}>
|
||||
Create Multiple
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/orders/create')}>
|
||||
Create Order
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Created Orders" value={counts.created} icon={Inventory2OutlinedIcon} caption="100%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Pending Orders" value={counts.pending} icon={HourglassEmptyOutlinedIcon} color="warning" caption="25%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Delivered Orders" value={counts.delivered} icon={CheckCircleOutlineIcon} color="success" caption="25%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Cancelled Orders" value={counts.cancelled} icon={CancelOutlinedIcon} color="error" caption="12.5%" /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ mt: 1.5 }}>
|
||||
{/* filter toolbar */}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search orders…" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 240 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button variant="outlined" size="medium" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<TextField select size="small" value={tenant} onChange={(e) => setTenant(e.target.value)} sx={{ minWidth: 170 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{[...new Set(orders.map((o) => o.tenant))].map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" defaultValue="all" sx={{ minWidth: 150 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{[...new Set(orders.map((o) => o.location))].map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }}>
|
||||
{TABS.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key]} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ px: 2, py: 1, bgcolor: 'primary.lighter' }}>
|
||||
<Typography variant="subtitle2" color="primary.dark">{selected.length} selected</Typography>
|
||||
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />}>Delete</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={selected.length > 0 && selected.length < paged.length}
|
||||
checked={paged.length > 0 && selected.length === paged.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? paged.map((o) => o.id) : [])}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Tenant</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Pickup</TableCell>
|
||||
<TableCell>Drop</TableCell>
|
||||
<TableCell align="center">QTY</TableCell>
|
||||
<TableCell align="right">COD</TableCell>
|
||||
<TableCell align="right">KMS</TableCell>
|
||||
<TableCell align="right">Charges</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((o) => (
|
||||
<TableRow key={o.id} hover selected={selected.includes(o.id)}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selected.includes(o.id)} onChange={() => toggle(o.id)} /></TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main', cursor: 'pointer' }} onClick={() => navigate(`/orders/${o.id}`)}>{o.id}</TableCell>
|
||||
<TableCell>{o.tenant}</TableCell>
|
||||
<TableCell>{o.location}</TableCell>
|
||||
<TableCell>{o.pickup}</TableCell>
|
||||
<TableCell>{o.drop}</TableCell>
|
||||
<TableCell align="center">{o.qty}</TableCell>
|
||||
<TableCell align="right">{o.cod ? inr(o.cod) : '—'}</TableCell>
|
||||
<TableCell align="right">{o.kms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary" noWrap>{o.notes || '—'}</Typography></TableCell>
|
||||
<TableCell><StatusChip status={o.status} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="View"><IconButton size="small" onClick={() => navigate(`/orders/${o.id}`)}><VisibilityOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Edit"><IconButton size="small"><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SpeedDial ariaLabel="Order actions" icon={<TuneIcon />} sx={{ position: 'fixed', bottom: 28, right: 28 }} FabProps={{ color: 'primary' }}>
|
||||
<SpeedDialAction icon={<AutoAwesomeOutlinedIcon />} tooltipTitle="AI Optimisation" onClick={() => navigate('/orders/assign')} />
|
||||
<SpeedDialAction icon={<PersonAddAltOutlinedIcon />} tooltipTitle="Manual Assign" onClick={() => navigate('/orders/assign')} />
|
||||
<SpeedDialAction icon={<DeleteOutlineIcon />} tooltipTitle="Delete" />
|
||||
</SpeedDial>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Grid, Card, Stack, Button, TextField, MenuItem, InputAdornment, Box, IconButton, Tooltip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, Typography,
|
||||
Dialog, DialogTitle, DialogContent, DialogActions
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import { ordersDetailReport, locations, tenantsList } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const STATUSES = ['all', 'created', 'pending', 'picked', 'active', 'delivered', 'cancelled'];
|
||||
|
||||
export default function OrdersDetails() {
|
||||
const [location, setLocation] = useState('all');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [loc2, setLoc2] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
const [mapRow, setMapRow] = useState(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
ordersDetailReport.filter((o) => {
|
||||
const matchStatus = status === 'all' || o.status === status;
|
||||
const matchTenant = tenant === 'all' || o.client === tenant;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[o.id, o.client, o.pickup, o.drop].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchStatus && matchTenant && matchSearch;
|
||||
}),
|
||||
[status, tenant, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Orders Details"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Orders Details' }]}
|
||||
action={
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }} flexWrap="wrap" useFlexGap>
|
||||
<TextField select size="small" value={tenant} onChange={(e) => setTenant(e.target.value)} sx={{ minWidth: 170 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={loc2} onChange={(e) => setLoc2(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<TextField select size="small" value={status} onChange={(e) => { setStatus(e.target.value); setPage(0); }} sx={{ minWidth: 150 }} label="Status">
|
||||
{STATUSES.map((s) => <MenuItem key={s} value={s}>{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small" placeholder="Search orders…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} sx={{ minWidth: 220 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button variant="contained" startIcon={<FileDownloadOutlinedIcon />} onClick={() => setExportOpen(true)}>
|
||||
Export Report
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell />
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Pickup</TableCell>
|
||||
<TableCell>Drop</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="center">Assigned</TableCell>
|
||||
<TableCell align="center">Accepted</TableCell>
|
||||
<TableCell align="center">Arrived</TableCell>
|
||||
<TableCell align="center">Picked</TableCell>
|
||||
<TableCell align="center">Active</TableCell>
|
||||
<TableCell align="center">Delivered</TableCell>
|
||||
<TableCell align="center">Cancelled</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
<TableCell align="right">KMS</TableCell>
|
||||
<TableCell align="right">Charges</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((o, i) => (
|
||||
<TableRow key={o.id} hover>
|
||||
<TableCell>{page * rpp + i + 1}</TableCell>
|
||||
<TableCell padding="checkbox">
|
||||
<Tooltip title="View route">
|
||||
<IconButton size="small" color="primary" onClick={() => setMapRow(o)}><MapOutlinedIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{o.client}</TableCell>
|
||||
<TableCell>{o.pickup}</TableCell>
|
||||
<TableCell>{o.drop}</TableCell>
|
||||
<TableCell><StatusChip status={o.status} /></TableCell>
|
||||
<TableCell align="center">{o.assigned}</TableCell>
|
||||
<TableCell align="center">{o.accepted}</TableCell>
|
||||
<TableCell align="center">{o.arrived}</TableCell>
|
||||
<TableCell align="center">{o.picked}</TableCell>
|
||||
<TableCell align="center">{o.active}</TableCell>
|
||||
<TableCell align="center">{o.delivered}</TableCell>
|
||||
<TableCell align="center">{o.cancelled}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary" noWrap>{o.notes || '—'}</Typography></TableCell>
|
||||
<TableCell align="right">{o.kms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[10, 25, 50]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Map dialog */}
|
||||
<Dialog open={!!mapRow} onClose={() => setMapRow(null)} fullScreen>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
Route — {mapRow?.id}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{mapRow?.pickup} → {mapRow?.drop}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={() => setMapRow(null)}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<MapPlaceholder height={520} label="Route" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Export dialog */}
|
||||
<Dialog open={exportOpen} onClose={() => setExportOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Export Report</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
The export will include {filtered.length} record(s) matching the current filters:
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
<Filter label="Location" value={location === 'all' ? 'All Locations' : location} />
|
||||
<Filter label="Tenant" value={tenant === 'all' ? 'All Tenants' : tenant} />
|
||||
<Filter label="Location (2)" value={loc2 === 'all' ? 'All Locations' : loc2} />
|
||||
<Filter label="Status" value={status === 'all' ? 'All Status' : status} />
|
||||
<Filter label="Date Range" value="Jun 01 – Jun 05" />
|
||||
<Filter label="Search" value={search || '—'} />
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setExportOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" startIcon={<FileDownloadOutlinedIcon />} onClick={() => setExportOpen(false)}>Download CSV</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ label, value }) {
|
||||
return (
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ textTransform: 'capitalize' }}>{value}</Typography>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState, Fragment } from 'react';
|
||||
import {
|
||||
Grid, Card, Stack, Button, TextField, MenuItem, Box, Collapse, IconButton,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography
|
||||
} from '@mui/material';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { ordersSummary, locations, tenantsList } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
// Show 0 in red, anything else normally.
|
||||
function NumCell({ value, align = 'center', bold = false }) {
|
||||
const zero = Number(value) === 0;
|
||||
return (
|
||||
<TableCell align={align} sx={{ fontWeight: bold ? 700 : 400, color: zero ? 'error.main' : 'inherit' }}>
|
||||
{value}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrdersSummary() {
|
||||
const [open, setOpen] = useState({});
|
||||
const [location, setLocation] = useState('all');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [loc2, setLoc2] = useState('all');
|
||||
|
||||
const toggle = (id) => setOpen((p) => ({ ...p, [id]: !p[id] }));
|
||||
|
||||
const totals = ordersSummary.reduce(
|
||||
(a, r) => ({
|
||||
oPending: a.oPending + r.orders.pending,
|
||||
oCancelled: a.oCancelled + r.orders.cancelled,
|
||||
oCompleted: a.oCompleted + r.orders.completed,
|
||||
dPending: a.dPending + r.deliveries.pending,
|
||||
dCancelled: a.dCancelled + r.deliveries.cancelled,
|
||||
dCompleted: a.dCompleted + r.deliveries.completed,
|
||||
collection: a.collection + r.collection,
|
||||
kms: a.kms + r.kms,
|
||||
actualKms: a.actualKms + r.actualKms,
|
||||
amount: a.amount + r.amount
|
||||
}),
|
||||
{ oPending: 0, oCancelled: 0, oCompleted: 0, dPending: 0, dCancelled: 0, dCompleted: 0, collection: 0, kms: 0, actualKms: 0, amount: 0 }
|
||||
);
|
||||
|
||||
const headSx = { bgcolor: 'primary.lighter', fontWeight: 700, color: 'primary.dark', whiteSpace: 'nowrap' };
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Orders Summary"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Orders Summary' }]}
|
||||
action={
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<TextField select size="small" value={tenant} onChange={(e) => setTenant(e.target.value)} sx={{ minWidth: 170 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={loc2} onChange={(e) => setLoc2(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={headSx} rowSpan={2} />
|
||||
<TableCell sx={headSx} rowSpan={2}>#</TableCell>
|
||||
<TableCell sx={headSx} rowSpan={2}>Tenant / Location</TableCell>
|
||||
<TableCell sx={headSx} colSpan={3} align="center">Orders</TableCell>
|
||||
<TableCell sx={headSx} colSpan={3} align="center">Deliveries</TableCell>
|
||||
<TableCell sx={headSx} rowSpan={2} align="right">Collection Amt</TableCell>
|
||||
<TableCell sx={headSx} rowSpan={2} align="center">Kms / Actual</TableCell>
|
||||
<TableCell sx={headSx} rowSpan={2} align="right">Amount</TableCell>
|
||||
<TableCell sx={headSx} rowSpan={2} align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={headSx} align="center">Pending</TableCell>
|
||||
<TableCell sx={headSx} align="center">Cancelled</TableCell>
|
||||
<TableCell sx={headSx} align="center">Completed</TableCell>
|
||||
<TableCell sx={headSx} align="center">Pending</TableCell>
|
||||
<TableCell sx={headSx} align="center">Cancelled</TableCell>
|
||||
<TableCell sx={headSx} align="center">Completed</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ordersSummary.map((r, idx) => (
|
||||
<Fragment key={r.id}>
|
||||
<TableRow hover>
|
||||
<TableCell padding="checkbox">
|
||||
<IconButton size="small" onClick={() => toggle(r.id)}>
|
||||
{open[r.id] ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle2">{r.tenant}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{r.location}</Typography>
|
||||
</TableCell>
|
||||
<NumCell value={r.orders.pending} />
|
||||
<NumCell value={r.orders.cancelled} />
|
||||
<NumCell value={r.orders.completed} />
|
||||
<NumCell value={r.deliveries.pending} />
|
||||
<NumCell value={r.deliveries.cancelled} />
|
||||
<NumCell value={r.deliveries.completed} />
|
||||
<TableCell align="right">{inr(r.collection)}</TableCell>
|
||||
<TableCell align="center">{r.kms} / {r.actualKms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(r.amount)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton size="small" onClick={() => toggle(r.id)}><VisibilityOutlinedIcon fontSize="small" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={!!open[r.id]} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ m: 1.5, p: 1.5, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Riders</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={headSx}>#</TableCell>
|
||||
<TableCell sx={headSx}>Rider</TableCell>
|
||||
<TableCell sx={headSx} align="center">Orders</TableCell>
|
||||
<TableCell sx={headSx} align="center">Deliveries</TableCell>
|
||||
<TableCell sx={headSx} align="center">Pending</TableCell>
|
||||
<TableCell sx={headSx} align="center">Cancelled</TableCell>
|
||||
<TableCell sx={headSx} align="center">Completed</TableCell>
|
||||
<TableCell sx={headSx} align="right">Collection Amt</TableCell>
|
||||
<TableCell sx={headSx} align="center">Kms / Actual</TableCell>
|
||||
<TableCell sx={headSx} align="right">Charges</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{r.riders.map((rd, i) => (
|
||||
<TableRow key={rd.rider} hover>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>{rd.rider}</TableCell>
|
||||
<NumCell value={rd.orders} />
|
||||
<NumCell value={rd.deliveries} />
|
||||
<NumCell value={rd.pending} />
|
||||
<NumCell value={rd.cancelled} />
|
||||
<NumCell value={rd.completed} />
|
||||
<TableCell align="right">{inr(rd.collection)}</TableCell>
|
||||
<TableCell align="center">{rd.kms} / {rd.actualKms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(rd.charges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* totals */}
|
||||
<TableRow sx={{ '& td': { bgcolor: 'primary.lighter', borderTop: 2, borderColor: 'primary.light' } }}>
|
||||
<TableCell />
|
||||
<TableCell colSpan={2} sx={{ fontWeight: 700 }}>Totals</TableCell>
|
||||
<NumCell value={totals.oPending} bold />
|
||||
<NumCell value={totals.oCancelled} bold />
|
||||
<NumCell value={totals.oCompleted} bold />
|
||||
<NumCell value={totals.dPending} bold />
|
||||
<NumCell value={totals.dCancelled} bold />
|
||||
<NumCell value={totals.dCompleted} bold />
|
||||
<TableCell align="right" sx={{ fontWeight: 700 }}>{inr(totals.collection)}</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 700 }}>{totals.kms} / {totals.actualKms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 700 }}>{inr(totals.amount)}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Paper, Stack, Button, TextField, InputAdornment, IconButton, Checkbox, Typography, Divider, Tooltip
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import { ridersLive } from '@/data/mock';
|
||||
|
||||
// spread active riders across the map at distinct positions
|
||||
const POSITIONS = [
|
||||
{ x: '24%', y: '32%' },
|
||||
{ x: '58%', y: '28%' },
|
||||
{ x: '40%', y: '60%' },
|
||||
{ x: '72%', y: '55%' },
|
||||
{ x: '30%', y: '72%' },
|
||||
{ x: '64%', y: '78%' }
|
||||
];
|
||||
|
||||
export default function RidersLogs() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState(ridersLive.filter((r) => r.active).map((r) => r.userid));
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
ridersLive.filter((r) =>
|
||||
!search || [r.name, r.phone, r.userid].join(' ').toLowerCase().includes(search.toLowerCase())
|
||||
),
|
||||
[search]
|
||||
);
|
||||
|
||||
const allChecked = filtered.length > 0 && filtered.every((r) => selected.includes(r.userid));
|
||||
const someChecked = filtered.some((r) => selected.includes(r.userid)) && !allChecked;
|
||||
|
||||
const toggle = (id) => setSelected((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
|
||||
const toggleAll = (checked) =>
|
||||
setSelected(checked ? [...new Set([...selected, ...filtered.map((r) => r.userid)])] : selected.filter((id) => !filtered.some((r) => r.userid === id)));
|
||||
|
||||
const mapRiders = ridersLive
|
||||
.filter((r) => r.active && selected.includes(r.userid))
|
||||
.map((r, i) => ({ ...POSITIONS[i % POSITIONS.length], active: true }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Riders Locations"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Riders Locations' }]}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', md: 'row' }, gap: 2.5, alignItems: 'stretch' }}>
|
||||
{/* Left side panel */}
|
||||
<Paper variant="outlined" sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<TextField
|
||||
fullWidth size="small" placeholder="Search Rider…" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Stack direction="row" alignItems="center" sx={{ px: 1, py: 0.5 }}>
|
||||
<Checkbox checked={allChecked} indeterminate={someChecked} onChange={(e) => toggleAll(e.target.checked)} />
|
||||
<Typography variant="subtitle2">All</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ pr: 1 }}>{selected.length} selected</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Box sx={{ flexGrow: 1, overflowY: 'auto', maxHeight: { xs: 360, md: 620 } }}>
|
||||
{filtered.map((r) => (
|
||||
<Stack key={r.userid} direction="row" alignItems="flex-start" spacing={0.5} sx={{ px: 1, py: 1, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Checkbox size="small" checked={selected.includes(r.userid)} onChange={() => toggle(r.userid)} sx={{ mt: -0.5 }} />
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.75}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: r.active ? 'success.main' : 'grey.400', flexShrink: 0 }} />
|
||||
<Typography variant="subtitle2" noWrap>{r.name}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{r.phone}</Typography>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="caption" color="text.secondary">{r.userid}</Typography>
|
||||
<Typography variant="caption" color={r.active ? 'success.main' : 'text.disabled'}>{r.lastLog}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', p: 2, textAlign: 'center' }}>No riders found</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Map area */}
|
||||
<Paper variant="outlined" sx={{ flexGrow: 1, p: 2, display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
|
||||
<Tooltip title="Menu"><IconButton size="small"><MenuIcon /></IconButton></Tooltip>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800', flexGrow: 1 }}>Riders Locations</Typography>
|
||||
<Button variant="contained" startIcon={<RefreshIcon />}>Refresh</Button>
|
||||
</Stack>
|
||||
<MapPlaceholder height={620} label="Riders Locations" showRoute={false} riders={mapRiders} />
|
||||
</Paper>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useState, Fragment } from 'react';
|
||||
import {
|
||||
Card, Stack, Button, TextField, MenuItem, Box, Collapse, IconButton, Chip, Tooltip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography,
|
||||
Dialog, DialogTitle, DialogContent
|
||||
} from '@mui/material';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import RoomOutlinedIcon from '@mui/icons-material/RoomOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import { ridersSummary, locations } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
function NumCell({ value, align = 'center' }) {
|
||||
const zero = Number(value) === 0;
|
||||
return <TableCell align={align} sx={{ color: zero ? 'error.main' : 'inherit' }}>{value}</TableCell>;
|
||||
}
|
||||
|
||||
function KmsChips({ kms, actual }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="center">
|
||||
<Chip size="small" label={`${kms} km`} sx={{ bgcolor: 'primary.lighter', color: 'primary.dark', fontWeight: 600 }} />
|
||||
<Chip size="small" label={`${actual} km`} variant="outlined" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RidersSummary() {
|
||||
const [open, setOpen] = useState({});
|
||||
const [location, setLocation] = useState('all');
|
||||
const [mapRider, setMapRider] = useState(null);
|
||||
|
||||
const toggle = (id) => setOpen((p) => ({ ...p, [id]: !p[id] }));
|
||||
const totalAmount = ridersSummary.reduce((a, r) => a + r.amount, 0);
|
||||
|
||||
const headSx = { bgcolor: 'primary.lighter', fontWeight: 700, color: 'primary.dark', whiteSpace: 'nowrap' };
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Riders Summary"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Riders Summary' }]}
|
||||
action={
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
</Stack>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={headSx}>#</TableCell>
|
||||
<TableCell sx={headSx}>Rider</TableCell>
|
||||
<TableCell sx={headSx} align="center">Orders</TableCell>
|
||||
<TableCell sx={headSx} align="center">Pending</TableCell>
|
||||
<TableCell sx={headSx} align="center">Cancelled</TableCell>
|
||||
<TableCell sx={headSx} align="center">Delivered</TableCell>
|
||||
<TableCell sx={headSx} align="center">KMS</TableCell>
|
||||
<TableCell sx={headSx} align="right">Amount</TableCell>
|
||||
<TableCell sx={headSx} align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ridersSummary.map((r, idx) => (
|
||||
<Fragment key={r.id}>
|
||||
<TableRow hover>
|
||||
<TableCell>{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<UserAvatar name={r.rider} size={32} />
|
||||
<Typography variant="subtitle2">{r.rider}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<NumCell value={r.orders} />
|
||||
<NumCell value={r.pending} />
|
||||
<NumCell value={r.cancelled} />
|
||||
<NumCell value={r.delivered} />
|
||||
<TableCell align="center"><KmsChips kms={r.kms} actual={r.actualKms} /></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(r.amount)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title={open[r.id] ? 'Collapse' : 'Expand'}>
|
||||
<IconButton size="small" onClick={() => toggle(r.id)}>
|
||||
{open[r.id] ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Location">
|
||||
<IconButton size="small" color="primary" onClick={() => setMapRider(r)}><RoomOutlinedIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={!!open[r.id]} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ m: 1.5, p: 1.5, bgcolor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Client Summary</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={headSx}>#</TableCell>
|
||||
<TableCell sx={headSx}>Client</TableCell>
|
||||
<TableCell sx={headSx} align="center">All</TableCell>
|
||||
<TableCell sx={headSx} align="center">Pending</TableCell>
|
||||
<TableCell sx={headSx} align="center">Completed</TableCell>
|
||||
<TableCell sx={headSx} align="center">Cancelled</TableCell>
|
||||
<TableCell sx={headSx} align="center">Kms</TableCell>
|
||||
<TableCell sx={headSx} align="right">Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{r.clients.map((c, i) => (
|
||||
<TableRow key={c.client} hover>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>{c.client}</TableCell>
|
||||
<NumCell value={c.all} />
|
||||
<NumCell value={c.pending} />
|
||||
<NumCell value={c.completed} />
|
||||
<NumCell value={c.cancelled} />
|
||||
<TableCell align="center"><KmsChips kms={c.kms} actual={c.actualKms} /></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(c.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" alignItems="center" spacing={1} sx={{ p: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle2" color="text.secondary">Total Amount</Typography>
|
||||
<Typography variant="h5" color="primary.main" sx={{ fontWeight: 700 }}>{inr(totalAmount)}</Typography>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!mapRider} onClose={() => setMapRider(null)} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{mapRider?.rider} — Location
|
||||
<IconButton onClick={() => setMapRider(null)}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<MapPlaceholder height={420} label="Rider Location" riders={[{ x: '50%', y: '45%', active: true }]} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, CardContent, Stack, Button, TextField, Divider, InputAdornment
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function CreateRider() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Rider"
|
||||
breadcrumbs={[{ label: 'Riders', to: '/riders' }, { label: 'Create Rider' }]}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Admin Name" placeholder="Enter admin name" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth label="Phone Number" placeholder="98450 11223"
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">+91</InputAdornment> }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Email Address" placeholder="rider@doormile.in" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} />
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Address" placeholder="Enter full address" multiline minRows={2} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Suburb" placeholder="Enter suburb" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="City" placeholder="Enter city" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="State" placeholder="Enter state" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Post Code" placeholder="e.g. 560102" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Door No" placeholder="e.g. 24" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Landmark" placeholder="Nearby landmark" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ mt: 3 }} />
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/riders')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/riders')}>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, CardContent, Stack, Button, TextField, MenuItem, Typography, Divider, Box, IconButton, InputAdornment
|
||||
} from '@mui/material';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { riders, tenantsList } from '@/data/mock';
|
||||
|
||||
const SHIFTS = ['Morning', 'Evening', 'Night'];
|
||||
const ACCOUNT_TYPES = ['Savings', 'Current'];
|
||||
const VEHICLES = ['Honda Activa', 'TVS Jupiter', 'Hero Splendor', 'Bajaj Pulsar', 'Honda Dio'];
|
||||
|
||||
const SectionTitle = ({ children }) => (
|
||||
<>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>{children}</Typography>
|
||||
<Divider sx={{ mt: 1, mb: 2.5 }} />
|
||||
</>
|
||||
);
|
||||
|
||||
export default function EditRider() {
|
||||
const navigate = useNavigate();
|
||||
const rider = riders[0];
|
||||
const [firstName] = rider.name.split(' ');
|
||||
const lastName = rider.name.split(' ').slice(1).join(' ');
|
||||
const [insuranceExpiry, setInsuranceExpiry] = useState(dayjs().add(8, 'month'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<IconButton size="small" onClick={() => navigate('/riders')}><ArrowBackIcon /></IconButton>
|
||||
<span>Edit Rider</span>
|
||||
</Stack>
|
||||
}
|
||||
breadcrumbs={[{ label: 'Riders', to: '/riders' }, { label: 'Edit Rider' }]}
|
||||
/>
|
||||
|
||||
<Card sx={{ mb: 10 }}>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
<SectionTitle>Contact Information</SectionTitle>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="First Name" defaultValue={firstName} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Last Name" defaultValue={lastName} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Phone Number" defaultValue={rider.phone} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Email" placeholder="rider@doormile.in" />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Address" defaultValue={rider.address} multiline minRows={2} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Location / Suburb" defaultValue={rider.address.split(',')[0]} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="City" defaultValue={(rider.address.split(',')[1] || '').trim()} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="State" placeholder="Enter state" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Identification No" placeholder="Aadhaar / ID number" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth label="Choose Partner" defaultValue="">
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle>Charges</SectionTitle>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth label="Shift Type" defaultValue={rider.shift}>
|
||||
{SHIFTS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Base Fare" defaultValue={rider.fare} InputProps={{ startAdornment: <InputAdornment position="start">₹</InputAdornment> }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Additional Kms" placeholder="Per extra km" InputProps={{ startAdornment: <InputAdornment position="start">₹</InputAdornment> }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Other Charges" placeholder="0" InputProps={{ startAdornment: <InputAdornment position="start">₹</InputAdornment> }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle>Bank Details</SectionTitle>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Account No" placeholder="Enter account number" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Account Name" defaultValue={rider.name} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth label="Account Type" defaultValue="Savings">
|
||||
{ACCOUNT_TYPES.map((a) => <MenuItem key={a} value={a}>{a}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Bank Name" placeholder="Enter bank name" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="IFSC Code" placeholder="e.g. HDFC0001234" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Branch" placeholder="Enter branch" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<SectionTitle>Vehicle Details</SectionTitle>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField select fullWidth label="Vehicle Name" defaultValue={rider.vehicle}>
|
||||
{VEHICLES.map((v) => <MenuItem key={v} value={v}>{v}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Vehicle No" defaultValue={rider.vehicleNo} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Model Year" placeholder="e.g. 2022" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Vehicle Color" placeholder="e.g. Black" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="License No" placeholder="Driving license number" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField fullWidth label="Insurance No" placeholder="Insurance policy number" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<DatePicker
|
||||
label="Insurance Expiry Date"
|
||||
value={insuranceExpiry}
|
||||
onChange={setInsuranceExpiry}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky', bottom: 0, py: 2, px: { xs: 2, md: 3 }, mt: -8,
|
||||
bgcolor: 'background.paper', borderTop: 1, borderColor: 'divider', zIndex: 2
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
|
||||
<Button variant="outlined" startIcon={<ArrowBackIcon />} onClick={() => navigate('/riders')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => navigate('/riders')}>
|
||||
Update
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { useState, useMemo, Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, Stack, Button, TextField, MenuItem, InputAdornment, Box, Tabs, Tab,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Tooltip,
|
||||
TablePagination, Typography, Chip, Collapse
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import PowerSettingsNewOutlinedIcon from '@mui/icons-material/PowerSettingsNewOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { riders, riderLogs, locations } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'ALL' },
|
||||
{ key: 'active', label: 'Active' }
|
||||
];
|
||||
|
||||
export default function Riders() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [location, setLocation] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
riders.filter((r) => {
|
||||
const matchTab = tabKey === 'all' ? true : r.status !== 'offline';
|
||||
const matchLocation = location === 'all' || r.address.includes(location);
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[r.id, r.userId, r.name, r.phone, r.address, r.vehicle, r.vehicleNo]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
return matchTab && matchLocation && matchSearch;
|
||||
}),
|
||||
[tabKey, location, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
const counts = {
|
||||
total: riders.length,
|
||||
active: riders.filter((r) => r.status === 'online').length,
|
||||
onDelivery: riders.filter((r) => r.status === 'on-delivery').length,
|
||||
offline: riders.filter((r) => r.status === 'offline').length,
|
||||
all: riders.length,
|
||||
activeTab: riders.filter((r) => r.status !== 'offline').length
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Riders"
|
||||
breadcrumbs={[{ label: 'Riders' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ sm: 'center' }}>
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 170 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/riders/create')}>
|
||||
Add Rider
|
||||
</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Total Riders" value={counts.total} icon={GroupsOutlinedIcon} caption="All registered" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Active" value={counts.active} icon={CheckCircleOutlineIcon} color="success" caption="Online now" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="On Delivery" value={counts.onDelivery} icon={TwoWheelerOutlinedIcon} color="info" caption="In transit" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Offline" value={counts.offline} icon={PowerSettingsNewOutlinedIcon} color="error" caption="Not available" /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ mt: 1.5 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search riders…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 240 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }}>
|
||||
{TABS.map((t, i) => (
|
||||
<Tab
|
||||
key={t.key}
|
||||
label={<TabLabelCount label={t.label} count={t.key === 'all' ? counts.all : counts.activeTab} active={tab === i} />}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>S.NO</TableCell>
|
||||
<TableCell>User ID</TableCell>
|
||||
<TableCell>Rider</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell>Vehicle</TableCell>
|
||||
<TableCell>Shift</TableCell>
|
||||
<TableCell>Time</TableCell>
|
||||
<TableCell align="right">Fare</TableCell>
|
||||
<TableCell align="right">Fuel</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="center">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((r, i) => (
|
||||
<Fragment key={r.id}>
|
||||
<TableRow hover>
|
||||
<TableCell>{page * rpp + i + 1}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{r.userId}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={r.name} size={32} />
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{r.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{r.phone}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{r.address}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{r.vehicle}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{r.vehicleNo}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{r.shift}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Chip size="small" label={r.start} sx={{ bgcolor: 'success.lighter', color: 'success.main', fontWeight: 600 }} />
|
||||
<Chip size="small" label={r.end} sx={{ bgcolor: 'error.lighter', color: 'error.main', fontWeight: 600 }} />
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(r.fare)}</TableCell>
|
||||
<TableCell align="right">{inr(r.fuel)}</TableCell>
|
||||
<TableCell><StatusChip status={r.status} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => navigate(`/riders/${r.id}/edit`)}><EditOutlinedIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={expanded === r.id ? 'Hide logs' : 'View logs'}>
|
||||
<IconButton size="small" onClick={() => setExpanded(expanded === r.id ? null : r.id)}>
|
||||
{expanded === r.id ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} sx={{ p: 0, border: 0 }}>
|
||||
<Collapse in={expanded === r.id} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2.5, bgcolor: 'grey.50' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1.5 }}>Rider Logs</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Battery</TableCell>
|
||||
<TableCell>Charging</TableCell>
|
||||
<TableCell>Speed</TableCell>
|
||||
<TableCell>Accuracy</TableCell>
|
||||
<TableCell>Time</TableCell>
|
||||
<TableCell>Order</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{riderLogs.map((log, li) => (
|
||||
<TableRow key={li}>
|
||||
<TableCell>{log.location}</TableCell>
|
||||
<TableCell>{log.battery}</TableCell>
|
||||
<TableCell>{log.charging}</TableCell>
|
||||
<TableCell>{log.speed}</TableCell>
|
||||
<TableCell>{log.accuracy}</TableCell>
|
||||
<TableCell>{log.time}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{log.order}</TableCell>
|
||||
<TableCell><StatusChip status={log.status} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
304
src/pages/team/TeamUsers.jsx
Normal file
304
src/pages/team/TeamUsers.jsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Chip, Link,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
TablePagination, Typography, CircularProgress, Alert, Tooltip,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import MailOutlineIcon from '@mui/icons-material/MailOutline';
|
||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
|
||||
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
||||
import SupportAgentOutlinedIcon from '@mui/icons-material/SupportAgentOutlined';
|
||||
import ManageAccountsOutlinedIcon from '@mui/icons-material/ManageAccountsOutlined';
|
||||
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
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 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 },
|
||||
manager: { color: 'success', icon: ManageAccountsOutlinedIcon },
|
||||
rep: { color: 'info', icon: BadgeOutlinedIcon },
|
||||
support: { color: 'warning', icon: SupportAgentOutlinedIcon }
|
||||
};
|
||||
const roleMeta = (role) => ROLE_META[String(role || '').toLowerCase()] || { color: 'secondary', icon: PersonOutlineOutlinedIcon };
|
||||
|
||||
// Avatar with a small role-coloured badge in the corner.
|
||||
function UserIdentity({ name, email, role }) {
|
||||
const { color, icon: RoleIcon } = roleMeta(role);
|
||||
const handle = email ? email.split('@')[0] : '';
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<UserAvatar name={name} size={40} sx={{ border: '2px solid', borderColor: `${color}.lighter` }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', right: -3, bottom: -3, width: 18, height: 18, borderRadius: '50%',
|
||||
bgcolor: `${color}.main`, color: '#fff', display: 'inline-flex', alignItems: 'center',
|
||||
justifyContent: 'center', border: '2px solid #fff'
|
||||
}}
|
||||
>
|
||||
<RoleIcon sx={{ fontSize: 11 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800', lineHeight: 1.25 }}>{name}</Typography>
|
||||
{handle && <Typography variant="caption" color="text.secondary">@{handle}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleCell({ role }) {
|
||||
const { color, icon: RoleIcon } = roleMeta(role);
|
||||
return (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<RoleIcon sx={{ fontSize: 15, ml: 0.5 }} />}
|
||||
label={titleCase(role)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
bgcolor: `${color === 'secondary' ? 'grey.100' : `${color}.lighter`}`,
|
||||
color: `${color === 'secondary' ? 'grey.700' : `${color}.dark`}`,
|
||||
'& .MuiChip-icon': { color: 'inherit' }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamUsers() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
|
||||
const [dialog, setDialog] = useState({ open: false, mode: 'add', initial: null });
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchPoints(COLLECTIONS.teamUsers)
|
||||
.then((points) => setUsers(points.map(toUser)))
|
||||
.catch((e) => setError(e.message || 'Failed to load team users'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const seen = [];
|
||||
users.forEach((u) => { if (!seen.includes(u.role)) seen.push(u.role); });
|
||||
return [{ key: 'all', label: 'All' }, ...seen.map((r) => ({ key: r, label: titleCase(r) }))];
|
||||
}, [users]);
|
||||
|
||||
const tabKey = tabs[Math.min(tab, tabs.length - 1)]?.key || 'all';
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: users.length };
|
||||
users.forEach((u) => { c[u.role] = (c[u.role] || 0) + 1; });
|
||||
return c;
|
||||
}, [users]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
users.filter((u) => {
|
||||
const matchTab = tabKey === 'all' || u.role === tabKey;
|
||||
const matchSearch =
|
||||
!search || [u.name, u.email, u.phone, u.role].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchSearch;
|
||||
}),
|
||||
[users, tabKey, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
const handleSaved = () => { setDialog({ open: false, mode: 'add', initial: null }); load(); };
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePoint(COLLECTIONS.teamUsers, toDelete.id);
|
||||
setToDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to delete user');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="App Users"
|
||||
breadcrumbs={[{ label: 'App Users' }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading}>Refresh</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })}>Add User</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 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' }}>
|
||||
<GroupsOutlinedIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>App Users Directory</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Manage console members, roles and access</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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={{ minWidth: 300 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{!loading && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<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 }} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||
{tabs.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ m: 2 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||
|
||||
<TableContainer>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||
<TableCell sx={{ width: 64 }}>S.No</TableCell>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Phone</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ border: 'none' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paged.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ border: 'none' }}>
|
||||
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paged.map((row, i) => (
|
||||
<TableRow key={row.id} hover>
|
||||
<TableCell><Typography variant="body2" color="text.secondary">{page * rpp + i + 1}</Typography></TableCell>
|
||||
<TableCell><UserIdentity name={row.name} email={row.email} role={row.role} /></TableCell>
|
||||
<TableCell>
|
||||
{row.email ? (
|
||||
<Link href={`mailto:${row.email}`} underline="hover" color="text.primary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, fontSize: '0.8125rem' }}>
|
||||
<MailOutlineIcon sx={{ fontSize: 15, color: 'grey.400' }} />{row.email}
|
||||
</Link>
|
||||
) : <Typography variant="body2" color="text.disabled">—</Typography>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.phone ? (
|
||||
<Link href={`tel:${row.phone}`} underline="hover" color="text.primary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, fontSize: '0.8125rem' }}>
|
||||
<PhoneOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />{row.phone}
|
||||
</Link>
|
||||
) : <Typography variant="body2" color="text.disabled">—</Typography>}
|
||||
</TableCell>
|
||||
<TableCell><RoleCell role={row.role} /></TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={() => setDialog({ open: true, mode: 'edit', initial: row })}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => setToDelete(row)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<UserFormDialog
|
||||
open={dialog.open}
|
||||
mode={dialog.mode}
|
||||
initial={dialog.initial}
|
||||
onClose={() => setDialog({ open: false, mode: 'add', initial: null })}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}>
|
||||
<DialogTitle>Delete user?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_auth collection. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={() => setToDelete(null)} disabled={deleting}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/pages/team/UserFormDialog.jsx
Normal file
86
src/pages/team/UserFormDialog.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||
MenuItem, Alert, CircularProgress, IconButton
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant';
|
||||
|
||||
const ROLES = ['admin', 'rep', 'manager'];
|
||||
|
||||
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '' };
|
||||
|
||||
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
||||
|
||||
export default function UserFormDialog({ open, mode, initial, onClose, onSaved }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm({ ...EMPTY, ...(initial || {}) });
|
||||
setError(null);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { setError('Name is required.'); return; }
|
||||
if (!form.email.trim()) { setError('Email is required.'); return; }
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone,
|
||||
role: form.role,
|
||||
...(form.pin ? { pin: String(form.pin) } : {})
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await setPayload(COLLECTIONS.teamUsers, initial.id, payload);
|
||||
} else {
|
||||
await createPoint(COLLECTIONS.teamUsers, payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to save user');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{isEdit ? 'Edit Team User' : 'Add Team User'}
|
||||
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Name *" value={form.name} onChange={set('name')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email *" value={form.email} onChange={set('email')} /></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 select fullWidth size="small" label="Role" value={form.role} onChange={set('role')}>
|
||||
{withValue(ROLES, form.role).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}>
|
||||
{isEdit ? 'Save Changes' : 'Create User'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
158
src/pages/tenants/ClientFormDialog.jsx
Normal file
158
src/pages/tenants/ClientFormDialog.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant';
|
||||
|
||||
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
|
||||
const STATUSES = ['newClient', 'contacted', 'onboarded', 'lost'];
|
||||
const FREQUENCIES = ['Daily', 'Weekly', 'Fortnightly', 'Monthly', 'Occasional'];
|
||||
const CONSENTS = ['basicOnly', 'full', 'none'];
|
||||
|
||||
const EMPTY = {
|
||||
name: '', 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: ''
|
||||
};
|
||||
|
||||
// Ensure a select always has its current value among the options.
|
||||
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
||||
|
||||
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm({ ...EMPTY, ...(initial || {}) });
|
||||
setError(null);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { setError('Client name is required.'); return; }
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const lat = form.surveyLat === '' ? undefined : Number(form.surveyLat);
|
||||
const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng);
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone,
|
||||
city: form.city,
|
||||
businessState: form.businessState,
|
||||
businessType: form.businessType,
|
||||
status: form.status,
|
||||
frequency: form.frequency,
|
||||
parcelVolume: Number(form.parcelVolume) || 0,
|
||||
activeContracts: Number(form.activeContracts) || 0,
|
||||
provider: form.provider,
|
||||
efficiency: form.efficiency,
|
||||
logisticsSegment: form.logisticsSegment,
|
||||
transitFrom: form.transitFrom,
|
||||
transitTo: form.transitTo,
|
||||
neighbourhood: form.neighbourhood,
|
||||
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 } } : {})
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await setPayload(COLLECTIONS.clients, initial.id, payload);
|
||||
} else {
|
||||
await createPoint(COLLECTIONS.clients, {
|
||||
...payload,
|
||||
clientId: `client_${Date.now()}`,
|
||||
surveySubmitted: false
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to save client');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{isEdit ? 'Edit Client' : 'Add Client'}
|
||||
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<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 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>)}
|
||||
</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>)}
|
||||
</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>)}
|
||||
</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}><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>
|
||||
|
||||
<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}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></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={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}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="overline" color="text.secondary">Other</Typography>
|
||||
<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>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}>
|
||||
{isEdit ? 'Save Changes' : 'Create Client'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid, Stack, Button, TextField, MenuItem, InputAdornment } from '@mui/material';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
|
||||
const COUNTRY_CODES = ['+91', '+1', '+44', '+61', '+971'];
|
||||
|
||||
export default function CreateClient() {
|
||||
const navigate = useNavigate();
|
||||
const [code, setCode] = useState('+91');
|
||||
const [form, setForm] = useState({
|
||||
adminName: '', phone: '', email: '', address: '',
|
||||
suburb: '', city: '', state: '', postcode: '', doorNo: '', landmark: ''
|
||||
});
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Client"
|
||||
breadcrumbs={[{ label: 'Tenants', to: '/tenants' }, { label: 'Create Client' }]}
|
||||
/>
|
||||
|
||||
<MainCard title="Client Details">
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth size="small" label="Admin Name" value={form.adminName} onChange={set('adminName')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth size="small" label="Phone Number" value={form.phone} onChange={set('phone')}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<TextField
|
||||
select variant="standard" value={code} onChange={(e) => setCode(e.target.value)}
|
||||
InputProps={{ disableUnderline: true }} sx={{ minWidth: 56 }}
|
||||
>
|
||||
{COUNTRY_CODES.map((c) => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
||||
</TextField>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth size="small" label="Email Address" value={form.email} onChange={set('email')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth size="small" label="Door No" value={form.doorNo} onChange={set('doorNo')} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth size="small" label="Address" value={form.address} onChange={set('address')} multiline minRows={2} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth size="small" label="Suburb" value={form.suburb} onChange={set('suburb')} />
|
||||
</Grid>
|
||||
<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}>
|
||||
<TextField fullWidth size="small" label="State" value={form.state} onChange={set('state')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth size="small" label="Post Code" value={form.postcode} onChange={set('postcode')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth size="small" label="Landmark" value={form.landmark} onChange={set('landmark')} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1.5} sx={{ mt: 3 }}>
|
||||
<Button variant="outlined" onClick={() => navigate('/tenants')}>Cancel</Button>
|
||||
<Button variant="contained" onClick={() => navigate('/tenants')}>Create</Button>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +1,396 @@
|
||||
import { useState, useMemo, Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useMemo, useEffect, Fragment } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab,
|
||||
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Grid,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
Tooltip, TablePagination, Typography, Collapse, Grid
|
||||
TablePagination, Typography, Collapse, CircularProgress, Alert, Tooltip, Chip, Divider, Link,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
|
||||
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
||||
|
||||
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 { tenants, tenantPricing } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
|
||||
import ClientFormDialog from './ClientFormDialog';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'inactive', label: 'Inactive' }
|
||||
];
|
||||
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) => {
|
||||
const k = String(v || '').toLowerCase();
|
||||
if (/high|good|excellent/.test(k)) return 'success';
|
||||
if (/med|average|moderate/.test(k)) return 'warning';
|
||||
if (/low|poor|bad/.test(k)) return 'error';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const FIELD_LABEL_SX = { textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700, color: 'grey.700' };
|
||||
|
||||
// A soft, theme-tinted pill for categorical values. Falls back to a dash placeholder.
|
||||
function Pill({ label, color = 'default' }) {
|
||||
if (label === undefined || label === null || 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` })
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Label + arbitrary node value (text, pill, or grouped chips).
|
||||
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 ReadField({ label, value }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{value || '—'}</Typography>
|
||||
</Box>
|
||||
<Field label={label}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: value ? 'grey.800' : 'grey.500', wordBreak: 'break-word' }}>
|
||||
{value || '—'}
|
||||
</Typography>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingTab() {
|
||||
// A bordered sub-card with a tinted header strip — frames each group of fields.
|
||||
function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 1.5 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />}>Add Pricing</Button>
|
||||
<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: `${accent}.lighter`, opacity: 0.999 }}>
|
||||
<Icon sx={{ fontSize: 18, color: `${accent}.main` }} />
|
||||
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Slab</TableCell>
|
||||
<TableCell align="right">Base Price</TableCell>
|
||||
<TableCell align="right">Min Kms</TableCell>
|
||||
<TableCell align="right">Price/Km</TableCell>
|
||||
<TableCell align="right">Other Charges</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tenantPricing.map((p, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{p.date}</TableCell>
|
||||
<TableCell>{p.slab}</TableCell>
|
||||
<TableCell align="right">{inr(p.basePrice)}</TableCell>
|
||||
<TableCell align="right">{p.minKms}</TableCell>
|
||||
<TableCell align="right">{inr(p.pricePerKm)}</TableCell>
|
||||
<TableCell align="right">{inr(p.otherCharges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Stack spacing={1.75} sx={{ p: 2 }}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTab({ tenant }) {
|
||||
const [form, setForm] = useState({
|
||||
name: tenant.name, contact: tenant.contact, phone: tenant.phone, email: tenant.email,
|
||||
address: tenant.address, city: tenant.city, postcode: tenant.postcode, lat: tenant.lat, lng: tenant.lng
|
||||
});
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
// Mini KPI tile used inside the Business card for the headline numbers.
|
||||
function StatTile({ label, value, icon: Icon, color = 'primary' }) {
|
||||
return (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Tenant Name" value={form.name} onChange={set('name')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Contact Person" value={form.contact} onChange={set('contact')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Contact Number" 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>
|
||||
<Grid item xs={12}><TextField fullWidth size="small" label="Address" value={form.address} onChange={set('address')} /></Grid>
|
||||
<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}><TextField fullWidth size="small" label="PostCode" value={form.postcode} onChange={set('postcode')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Latitude" value={form.lat} onChange={set('lat')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Longitude" value={form.lng} onChange={set('lng')} /></Grid>
|
||||
</Grid>
|
||||
<Stack direction="row" justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
||||
<Button variant="contained">Update</Button>
|
||||
<Box sx={{ flex: 1, p: 1.5, borderRadius: 1.5, border: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 32, height: 32, borderRadius: 1, bgcolor: `${color}.lighter`, color: `${color}.main`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.1 }}>{value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantRow({ row, index }) {
|
||||
// Compact metric used inline in the table so key numbers show without expanding.
|
||||
function Metric({ label, value, color = 'grey.800' }) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', minWidth: 56 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color, lineHeight: 1.2 }}>{value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>{label}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientRow({ row, index, onEdit, onDelete }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inner, setInner] = useState(0);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TableRow hover sx={{ '& > *': { borderBottom: open ? 'unset' : undefined } }}>
|
||||
<TableRow
|
||||
hover
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'& > *': { borderBottom: open ? 'unset' : undefined },
|
||||
...(open && { bgcolor: 'primary.lighter', '&:hover': { bgcolor: 'primary.lighter' } })
|
||||
}}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<IconButton size="small" onClick={() => setOpen((o) => !o)}>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}>
|
||||
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 700, color: 'primary.main', bgcolor: 'primary.lighter', px: 1, py: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'primary.light', whiteSpace: 'nowrap' }}>
|
||||
{row.logicalId}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={row.name} size={34} />
|
||||
<Box>
|
||||
<UserAvatar name={row.name} size={38} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{row.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.volume} orders / mo</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.4 }}>
|
||||
{row.businessType && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={titleCase(row.businessType)}
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: 'grey.100', color: 'grey.700' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{row.contact}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.phone}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<PhoneOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />
|
||||
<Box>
|
||||
<Typography variant="body2">{row.phone || '—'}</Typography>
|
||||
{row.frequency && <Typography variant="caption" color="text.secondary">{row.frequency}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{row.address}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.city} · {row.postcode}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<PlaceOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />
|
||||
<Box>
|
||||
<Typography variant="body2">{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</Typography>
|
||||
{row.neighbourhood && <Typography variant="caption" color="text.secondary">{row.neighbourhood}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" justifyContent="center" divider={<Divider orientation="vertical" flexItem />}>
|
||||
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
|
||||
<Metric label="Contracts" value={row.activeContracts} color="primary.main" />
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={row.status} /></TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}>
|
||||
<TableCell colSpan={8} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ m: 2, borderRadius: 1, border: 1, borderColor: 'divider', overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
|
||||
<Tabs value={inner} onChange={(_, v) => setInner(v)} sx={{ px: 2 }}>
|
||||
<Tab label="Details" />
|
||||
<Tab label="Pricing" />
|
||||
<Tab label="Edit" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
{inner === 0 && (
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Name" value={row.name} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Contact Person" value={row.contact} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Phone" value={row.phone} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="E-Mail" value={row.email} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Address" value={row.address} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="City" value={row.city} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="PostCode" value={row.postcode} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Latitude" value={row.lat} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Longitude" value={row.lng} /></Grid>
|
||||
<Box sx={{ m: 2, borderRadius: 2.5, border: 1, borderColor: 'divider', overflow: 'hidden', boxShadow: '0 4px 16px rgba(0,0,0,0.06)' }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||
spacing={1.5}
|
||||
sx={{
|
||||
px: 2.5, py: 1.75, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)`
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<UserAvatar name={row.name} size={40} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }}>{row.name}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25 }}>
|
||||
<BadgeOutlinedIcon sx={{ fontSize: 14, color: 'grey.400' }} />
|
||||
<Typography variant="caption" color="text.secondary">{row.clientId}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<StatusChip status={row.status} sx={{ ml: 0.5 }} />
|
||||
</Stack>
|
||||
<Button size="small" variant="contained" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(row)}>Edit Client</Button>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: 'grey.50' }}>
|
||||
<Grid container spacing={2.5} alignItems="stretch">
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={StorefrontOutlinedIcon} title="Business" accent="primary">
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<StatTile label="Parcel Volume" value={Number(row.parcelVolume).toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" />
|
||||
<StatTile label="Active Contracts" value={row.activeContracts} icon={HandshakeOutlinedIcon} color="primary" />
|
||||
</Stack>
|
||||
<Field label="Client ID">
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'grey.800' }}>{row.clientId}</Typography>
|
||||
</Field>
|
||||
<Field label="Business Type"><Pill label={row.businessType && titleCase(row.businessType)} color="primary" /></Field>
|
||||
<Field label="Order Frequency"><Pill label={row.frequency && titleCase(row.frequency)} color="info" /></Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
)}
|
||||
{inner === 1 && <PricingTab />}
|
||||
{inner === 2 && <EditTab tenant={row} />}
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={LocalShippingOutlinedIcon} title="Logistics" accent="primary">
|
||||
<Field label="Logistics Segment">
|
||||
{row.logisticsSegment ? (
|
||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||
{String(row.logisticsSegment).split(/[,/]/).map((seg) => seg.trim()).filter(Boolean).map((seg) => (
|
||||
<Pill key={seg} label={titleCase(seg)} color="primary" />
|
||||
))}
|
||||
</Stack>
|
||||
) : <Pill label={null} />}
|
||||
</Field>
|
||||
<ReadField label="Current Provider" value={row.provider} />
|
||||
<Field label="Efficiency"><Pill label={row.efficiency && titleCase(row.efficiency)} color={efficiencyTone(row.efficiency)} /></Field>
|
||||
<Field label="Transit Route">
|
||||
{(() => {
|
||||
const stops = [row.transitFrom, ...String(row.transitTo || '').split(',')]
|
||||
.map((s) => s.trim()).filter(Boolean);
|
||||
if (!stops.length) return <Pill label={null} />;
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
{stops.map((stop, idx) => (
|
||||
<Fragment key={`${stop}-${idx}`}>
|
||||
{idx > 0 && <ArrowRightAltIcon sx={{ fontSize: 20, color: 'primary.main' }} />}
|
||||
<Pill label={titleCase(stop)} color={idx === 0 ? 'default' : 'info'} />
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={PlaceOutlinedIcon} title="Location & Survey" accent="primary">
|
||||
<Field label="City / State">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{[row.city, row.businessState].filter(Boolean).join(', ') || '—'}</Typography>
|
||||
</Field>
|
||||
<ReadField label="Neighbourhood" value={row.neighbourhood} />
|
||||
<Field label="Survey Address">
|
||||
<Stack direction="row" spacing={1} sx={{ p: 1.25, 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.surveyAddress || '—'}</Typography>
|
||||
</Stack>
|
||||
</Field>
|
||||
<Field label="Coordinates">
|
||||
{row.surveyLat && row.surveyLng ? (
|
||||
<Stack direction="row" spacing={1.25} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Tooltip title={`${row.surveyLat}, ${row.surveyLng}`} arrow placement="top">
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ cursor: 'default' }}>
|
||||
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800', borderBottom: '1px dotted', borderColor: 'grey.300' }}>
|
||||
{[row.neighbourhood, row.city].filter(Boolean).join(', ') || 'Pinned location'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${row.surveyLat},${row.surveyLng}`}
|
||||
target="_blank" rel="noopener"
|
||||
size="small" variant="outlined" startIcon={<MapOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
py: 0.25, px: 1.25, minWidth: 0, fontSize: '0.75rem', fontWeight: 600, borderRadius: 5,
|
||||
color: 'primary.main', borderColor: 'primary.100', bgcolor: 'primary.lighter',
|
||||
'&:hover': { borderColor: 'primary.main', bgcolor: 'primary.lighter' }
|
||||
}}
|
||||
>
|
||||
View on map
|
||||
</Button>
|
||||
</Stack>
|
||||
) : <Typography variant="body2" sx={{ color: 'grey.500' }}>—</Typography>}
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<SectionCard icon={InfoOutlinedIcon} title="Contact & Compliance" accent="primary">
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Phone" value={row.phone} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><Field label="Data Consent"><Pill label={row.dataConsent && titleCase(row.dataConsent)} color={consentTone(row.dataConsent)} /></Field></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Last Updated" value={row.lastUpdated} /></Grid>
|
||||
{row.notes && (
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ p: 2, borderRadius: 1.5, bgcolor: 'warning.lighter', border: 1, borderColor: 'warning.light' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<NotesOutlinedIcon sx={{ fontSize: 16, color: 'warning.dark' }} />
|
||||
<Typography variant="caption" sx={{ color: 'warning.dark', textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700 }}>Notes</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'grey.800' }}>{row.notes}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
@@ -165,85 +401,189 @@ function TenantRow({ row, index }) {
|
||||
}
|
||||
|
||||
export default function Tenants() {
|
||||
const navigate = useNavigate();
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [search, setSearch] = useState(searchParams.get('q') || '');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
// Keep the search box in sync when navigated here with a ?q= query (e.g. from the top search bar).
|
||||
useEffect(() => {
|
||||
const q = searchParams.get('q');
|
||||
if (q != null) { setSearch(q); setPage(0); }
|
||||
}, [searchParams]);
|
||||
|
||||
const [dialog, setDialog] = useState({ open: false, mode: 'add', initial: null });
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchPoints(COLLECTIONS.clients)
|
||||
.then((points) => setClients(points.map(toClient)))
|
||||
.catch((e) => setError(e.message || 'Failed to load clients'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: clients.length,
|
||||
newCount: clients.filter((c) => c.status === 'newClient').length,
|
||||
parcels: clients.reduce((s, c) => s + (Number(c.parcelVolume) || 0), 0),
|
||||
contracts: clients.reduce((s, c) => s + (Number(c.activeContracts) || 0), 0)
|
||||
}), [clients]);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const seen = [];
|
||||
clients.forEach((c) => { if (!seen.includes(c.status)) seen.push(c.status); });
|
||||
return [{ key: 'all', label: 'All' }, ...seen.map((s) => ({ key: s, label: titleCase(s) }))];
|
||||
}, [clients]);
|
||||
|
||||
const tabKey = tabs[Math.min(tab, tabs.length - 1)]?.key || 'all';
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = {};
|
||||
TABS.forEach((t) => { c[t.key] = tenants.filter((d) => d.status === t.key).length; });
|
||||
const c = { all: clients.length };
|
||||
clients.forEach((cl) => { c[cl.status] = (c[cl.status] || 0) + 1; });
|
||||
return c;
|
||||
}, []);
|
||||
}, [clients]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
tenants.filter((t) => {
|
||||
const matchTab = t.status === tabKey;
|
||||
clients.filter((t) => {
|
||||
const matchTab = tabKey === 'all' || t.status === tabKey;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[t.name, t.contact, t.email, t.phone, t.city].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
[t.name, t.phone, t.city, t.businessType, t.clientId, t.neighbourhood]
|
||||
.join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchSearch;
|
||||
}),
|
||||
[tabKey, search]
|
||||
[clients, tabKey, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
const handleSaved = () => { setDialog({ open: false, mode: 'add', initial: null }); load(); };
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePoint(COLLECTIONS.clients, toDelete.id);
|
||||
setToDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to delete client');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Tenants"
|
||||
breadcrumbs={[{ label: 'Tenants' }]}
|
||||
title="Clients"
|
||||
breadcrumbs={[{ label: 'Clients' }]}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/tenants/create')}>
|
||||
Create Client
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading}>Refresh</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })}>Add Client</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Grid container spacing={2.5} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} 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()} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 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' }}>
|
||||
<ApartmentOutlinedIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>Client Directory</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Browse, search and manage every client account</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search clients…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 260 }}
|
||||
size="small" placeholder="Search by name, phone, city, ID…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 300 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{!loading && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{filtered.length} {filtered.length === 1 ? 'client' : 'clients'}
|
||||
</Typography>
|
||||
<Chip size="small" label="live · doormile_clients" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }}>
|
||||
{TABS.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key]} active={tab === i} />} />
|
||||
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||
{tabs.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ m: 2 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Table sx={{ minWidth: 900 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||
<TableCell padding="checkbox" />
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Contact</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell align="center">Volume</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.length === 0 ? (
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ border: 'none' }}>
|
||||
<EmptyState title="No tenants found" caption="Try a different tab or search term." />
|
||||
<TableCell colSpan={8} sx={{ border: 'none' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paged.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ border: 'none' }}>
|
||||
<EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paged.map((row, i) => <TenantRow key={row.id} row={row} index={page * rpp + i} />)
|
||||
paged.map((row, i) => (
|
||||
<ClientRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
index={page * rpp + i}
|
||||
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
|
||||
onDelete={(r) => setToDelete(r)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -253,6 +593,27 @@ export default function Tenants() {
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ClientFormDialog
|
||||
open={dialog.open}
|
||||
mode={dialog.mode}
|
||||
initial={dialog.initial}
|
||||
onClose={() => setDialog({ open: false, mode: 'add', initial: null })}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}>
|
||||
<DialogTitle>Delete client?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_clients collection. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={() => setToDelete(null)} disabled={deleting}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
108
src/utils/qdrant.js
Normal file
108
src/utils/qdrant.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// ==============================|| 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] })
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,20 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true
|
||||
open: true,
|
||||
// Proxy Qdrant so the api-key stays server-side and the browser avoids CORS.
|
||||
// Frontend calls /qdrant/... → forwarded to the Qdrant cluster with the key injected.
|
||||
proxy: {
|
||||
'/qdrant': {
|
||||
target: 'http://66.116.207.225:6333',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/qdrant/, ''),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.setHeader('api-key', 'Package@321#');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user