diff --git a/src/pages/nearle/appUsers/appUsers.js b/src/pages/nearle/appUsers/appUsers.js
new file mode 100644
index 0000000..413e574
--- /dev/null
+++ b/src/pages/nearle/appUsers/appUsers.js
@@ -0,0 +1,335 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
+import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+// ============================================================================
+// GET /admin/users confirmed live: { id, email, first_name, phone, role,
+// status } — role is a free-text string ("admin", "rep" both observed
+// live; the auth doc's "1 admin, 3 manager, 4 executive" describes the JWT
+// roleid encoding for login, not this list's field). No `name`/`roleid` at
+// all. POST/PUT body shape for create/edit is still unconfirmed (write
+// endpoints, not exercised live this pass) — kept as first_name/phone/role
+// to match the GET shape.
+// ============================================================================
+
+const DT = {
+ radiusCard: 14,
+ radiusField: 10,
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ borderHover: '#cbd5e1',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const ring = (c) => a(c, '26');
+const BRAND = '#C01227';
+const ROLES = ['admin', 'manager', 'executive', 'rep'];
+const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '—');
+
+const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
+
+const AppUsers = () => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const queryClient = useQueryClient();
+ const [searchword, setSearchword] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(emptyForm);
+
+ const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers });
+
+ const rows = useMemo(() => {
+ if (!debouncedSearch) return users;
+ const q = debouncedSearch.toLowerCase().trim();
+ return users.filter((row) => [row.first_name, row.email, row.phone].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
+ }, [users, debouncedSearch]);
+
+ const stats = useMemo(() => {
+ const total = users.length;
+ const admins = users.filter((u) => String(u.role).toLowerCase() === 'admin').length;
+ return { total, admins };
+ }, [users]);
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const saveMutation = useMutation({
+ mutationFn: (payload) => (payload.id ? updateAppUser(payload.id, payload.data) : createAppUser(payload.data)),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast(form.id ? 'User updated' : 'User created', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-app-users'] });
+ setDialogOpen(false);
+ } else {
+ opentoast(res.message || 'Failed to save user');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save user')
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id) => deleteAppUser(id),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('User deleted', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-app-users'] });
+ } else {
+ opentoast(res.message || 'Failed to delete user');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete user')
+ });
+
+ const openCreate = () => {
+ setForm(emptyForm);
+ setDialogOpen(true);
+ };
+
+ const openEdit = (row) => {
+ setForm({
+ id: row.id,
+ first_name: row.first_name || '',
+ email: row.email || '',
+ phone: row.phone || '',
+ password: '',
+ role: row.role || 'admin',
+ status: row.status || 'Active'
+ });
+ setDialogOpen(true);
+ };
+
+ const handleDelete = (row) => {
+ if (window.confirm(`Delete staff user "${row.first_name}"?`)) deleteMutation.mutate(row.id);
+ };
+
+ const handleSave = () => {
+ if (!form.first_name || !form.email || (!form.id && !form.password)) {
+ opentoast('Fill Name, Email' + (!form.id ? ' and Password' : ''));
+ return;
+ }
+ const { id, ...data } = form;
+ if (id && !data.password) delete data.password;
+ saveMutation.mutate({ id, data });
+ };
+
+ return (
+ <>
+ {(isLoading || saveMutation.isPending || deleteMutation.isPending) && }
+
+ } onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
+ New User
+
+ }
+ />
+
+
+
+ } color={BRAND} />
+
+
+ } color="#6366f1" />
+
+
+
+
+
+
+ {users.length} total · {rows.length} shown
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ Name
+ Email
+ Phone
+ Role
+ Status
+ Actions
+
+
+
+ {isLoading && }
+ {rows.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No staff users to show
+
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {index + 1}
+
+
+ {row.first_name}
+
+
+ {row.email}
+ {row.phone || '—'}
+ {roleLabel(row.role)}
+ {row.status || '—'}
+
+
+
+ openEdit(row)} sx={{ color: BRAND }}>
+
+
+
+
+ handleDelete(row)} sx={{ color: '#ef4444' }}>
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ >
+ );
+};
+
+export default AppUsers;
diff --git a/src/pages/nearle/competitiveIntel/competitiveIntel.js b/src/pages/nearle/competitiveIntel/competitiveIntel.js
new file mode 100644
index 0000000..0b0d5f9
--- /dev/null
+++ b/src/pages/nearle/competitiveIntel/competitiveIntel.js
@@ -0,0 +1,386 @@
+import React, { useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ InputLabel,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography
+} from '@mui/material';
+import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import PageHeader from 'components/nearle_components/PageHeader';
+import {
+ getCompetitorBranches,
+ createCompetitorBranch,
+ updateCompetitorBranch,
+ deleteCompetitorBranch,
+ getCarrierPricing,
+ createCarrierPricing,
+ updateCarrierPricing,
+ deleteCarrierPricing
+} from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+// ============================================================================
+// Neither /admin/competitor-branches nor /admin/carrier-pricing document a
+// request/response body anywhere — express-console-api.md lists only the
+// routes. The fields below (branchname/city/address for branches,
+// carriername/vehicletype/baseprice/priceperkm for pricing) are a best-effort
+// guess following the naming conventions used elsewhere in this API and are
+// UNVERIFIED — expect the create/edit calls here to need correction once a
+// real request/response has been seen.
+// ============================================================================
+
+const DT = {
+ radiusCard: 14,
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ divider: '#f1f5f9',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const BRAND = '#C01227';
+
+const TABS = [
+ { key: 'branches', label: 'Competitor Branches' },
+ { key: 'pricing', label: 'Carrier Pricing' }
+];
+
+const emptyBranch = { id: null, branchname: '', competitorname: '', city: '', address: '' };
+const emptyCarrier = { id: null, carriername: '', vehicletype: '', baseprice: '', priceperkm: '' };
+
+const CompetitiveIntel = () => {
+ const queryClient = useQueryClient();
+ const [tab, setTab] = useState('branches');
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [branchForm, setBranchForm] = useState(emptyBranch);
+ const [carrierForm, setCarrierForm] = useState(emptyCarrier);
+
+ const { data: branches = [], isLoading: branchesLoading } = useQuery({
+ queryKey: ['competitor-branches'],
+ queryFn: getCompetitorBranches
+ });
+ const { data: carrierPricing = [], isLoading: carrierLoading } = useQuery({
+ queryKey: ['carrier-pricing'],
+ queryFn: getCarrierPricing
+ });
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const branchMutation = useMutation({
+ mutationFn: (payload) => (payload.id ? updateCompetitorBranch(payload.id, payload.data) : createCompetitorBranch(payload.data)),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast(branchForm.id ? 'Branch updated' : 'Branch created', 'success');
+ queryClient.invalidateQueries({ queryKey: ['competitor-branches'] });
+ setDialogOpen(false);
+ } else {
+ opentoast(res.message || 'Failed to save branch');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save branch')
+ });
+
+ const branchDeleteMutation = useMutation({
+ mutationFn: (id) => deleteCompetitorBranch(id),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Branch deleted', 'success');
+ queryClient.invalidateQueries({ queryKey: ['competitor-branches'] });
+ } else opentoast(res.message || 'Failed to delete branch');
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete branch')
+ });
+
+ const carrierMutation = useMutation({
+ mutationFn: (payload) => (payload.id ? updateCarrierPricing(payload.id, payload.data) : createCarrierPricing(payload.data)),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast(carrierForm.id ? 'Carrier pricing updated' : 'Carrier pricing created', 'success');
+ queryClient.invalidateQueries({ queryKey: ['carrier-pricing'] });
+ setDialogOpen(false);
+ } else {
+ opentoast(res.message || 'Failed to save carrier pricing');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save carrier pricing')
+ });
+
+ const carrierDeleteMutation = useMutation({
+ mutationFn: (id) => deleteCarrierPricing(id),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Carrier pricing deleted', 'success');
+ queryClient.invalidateQueries({ queryKey: ['carrier-pricing'] });
+ } else opentoast(res.message || 'Failed to delete carrier pricing');
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete carrier pricing')
+ });
+
+ const idOf = (row) => row.id ?? row.competitorbranchid ?? row.carrierpricingid;
+
+ const openCreate = () => {
+ if (tab === 'branches') setBranchForm(emptyBranch);
+ else setCarrierForm(emptyCarrier);
+ setDialogOpen(true);
+ };
+
+ const openEdit = (row) => {
+ if (tab === 'branches') {
+ setBranchForm({
+ id: idOf(row),
+ branchname: row.branchname || '',
+ competitorname: row.competitorname || '',
+ city: row.city || '',
+ address: row.address || ''
+ });
+ } else {
+ setCarrierForm({
+ id: idOf(row),
+ carriername: row.carriername || '',
+ vehicletype: row.vehicletype || '',
+ baseprice: row.baseprice ?? '',
+ priceperkm: row.priceperkm ?? ''
+ });
+ }
+ setDialogOpen(true);
+ };
+
+ const handleDelete = (row) => {
+ if (!window.confirm('Delete this record?')) return;
+ if (tab === 'branches') branchDeleteMutation.mutate(idOf(row));
+ else carrierDeleteMutation.mutate(idOf(row));
+ };
+
+ const handleSave = () => {
+ if (tab === 'branches') {
+ if (!branchForm.branchname) {
+ opentoast('Fill Branch Name');
+ return;
+ }
+ const { id, ...data } = branchForm;
+ branchMutation.mutate({ id, data });
+ } else {
+ if (!carrierForm.carriername || !carrierForm.baseprice) {
+ opentoast('Fill Carrier Name and Base Price');
+ return;
+ }
+ const { id, ...data } = carrierForm;
+ carrierMutation.mutate({ id, data: { ...data, baseprice: Number(data.baseprice) || 0, priceperkm: Number(data.priceperkm) || 0 } });
+ }
+ };
+
+ const rows = tab === 'branches' ? branches : carrierPricing;
+ const isLoading = tab === 'branches' ? branchesLoading : carrierLoading;
+
+ return (
+ <>
+ {(isLoading || branchMutation.isPending || carrierMutation.isPending) && }
+
+ } onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
+ {tab === 'branches' ? 'New Branch' : 'New Carrier Rate'}
+
+ }
+ />
+
+
+ {TABS.map((t) => (
+ setTab(t.key)}
+ sx={{
+ px: 2,
+ py: 0.75,
+ borderRadius: 999,
+ cursor: 'pointer',
+ fontWeight: 700,
+ fontSize: 13,
+ bgcolor: tab === t.key ? BRAND : soft(BRAND),
+ color: tab === t.key ? '#fff' : BRAND,
+ border: `1px solid ${tab === t.key ? BRAND : soft(BRAND)}`
+ }}
+ >
+ {t.label}
+
+ ))}
+
+
+
+
+
+
+ {tab === 'branches' ? (
+
+ #
+ Branch Name
+ Competitor
+ City
+ Address
+ Actions
+
+ ) : (
+
+ #
+ Carrier
+ Vehicle Type
+ Base Price
+ Price/Km
+ Actions
+
+ )}
+
+
+ {rows.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No records to show
+
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {index + 1}
+ {tab === 'branches' ? (
+ <>
+ {row.branchname || '—'}
+ {row.competitorname || '—'}
+ {row.city || '—'}
+ {row.address || '—'}
+ >
+ ) : (
+ <>
+ {row.carriername || '—'}
+ {row.vehicletype || '—'}
+ {row.baseprice ?? '—'}
+ {row.priceperkm ?? '—'}
+ >
+ )}
+
+
+
+ openEdit(row)} sx={{ color: BRAND }}>
+
+
+
+
+ handleDelete(row)} sx={{ color: '#ef4444' }}>
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ >
+ );
+};
+
+export default CompetitiveIntel;
diff --git a/src/pages/nearle/customers/customers.js b/src/pages/nearle/customers/customers.js
new file mode 100644
index 0000000..c2856f8
--- /dev/null
+++ b/src/pages/nearle/customers/customers.js
@@ -0,0 +1,358 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Button,
+ Grid,
+ IconButton,
+ InputLabel,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import { MdPeopleAlt, MdOutlinePeopleAlt, MdEdit, MdPhone, MdMail, MdOutlineMailOutline, MdOutlinePhoneAndroid } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
+import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
+import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+// ============================================================================
+// GET /admin/customers confirmed live: { appcustomerid, createdat, email,
+// name, phone, totalbookings } — no city/address/firstname field exists at
+// all. PATCH /admin/customers/:id is the only mutation the API exposes;
+// there is no create/delete here by design ("Tenant-scoped through their
+// bookings" — a customer only exists once they've ordered through a tenant).
+// ============================================================================
+
+const DT = {
+ radiusCard: 14,
+ radiusField: 10,
+ shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
+ shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ borderHover: '#cbd5e1',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const ring = (c) => a(c, '26');
+const edge = (c) => a(c, '55');
+const BRAND = '#C01227';
+
+const AccentAvatar = ({ color, size = 24, children }) => (
+ {children}
+);
+
+const custId = (row) => row.appcustomerid;
+const custName = (row) => row.name || '—';
+const custPhone = (row) => row.phone || '—';
+const custEmail = (row) => row.email || '—';
+const custBookings = (row) => row.totalbookings ?? 0;
+
+const Customers = () => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const queryClient = useQueryClient();
+ const [searchword, setSearchword] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [editRow, setEditRow] = useState(null);
+ const [form, setForm] = useState({});
+
+ const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
+
+ const rows = useMemo(() => {
+ if (!debouncedSearch) return customers;
+ const q = debouncedSearch.toLowerCase().trim();
+ return customers.filter((row) =>
+ [custName(row), custPhone(row), custEmail(row)].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
+ );
+ }, [customers, debouncedSearch]);
+
+ const stats = useMemo(() => {
+ const total = customers.length;
+ const withEmail = customers.filter((c) => c.email).length;
+ return { total, withEmail };
+ }, [customers]);
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const saveMutation = useMutation({
+ mutationFn: () => updateAdminCustomer(custId(editRow), form),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Customer updated', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
+ setEditRow(null);
+ } else {
+ opentoast(res.message || 'Failed to update customer');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update customer')
+ });
+
+ const openEdit = (row) => {
+ setEditRow(row);
+ setForm({
+ name: row.name || '',
+ phone: row.phone || '',
+ email: row.email || ''
+ });
+ };
+
+ return (
+ <>
+ {isLoading && }
+
+
+
+
+
+ } color={BRAND} />
+
+
+ } color="#0ea5e9" />
+
+
+
+
+
+
+ {customers.length} total · {rows.length} shown
+
+
+
+
+
+
+
+
+ {isMobile ? (
+ rows.length === 0 && !isLoading ? (
+
+
+
+
+
+ No customers to show
+
+
+ ) : (
+
+ {rows.map((row, index) => (
+
+
+
+
+
+
+ {custName(row)}
+
+
+ openEdit(row)} sx={{ color: BRAND }}>
+
+
+
+ }
+ >
+
+
+
+
+
+
+ ))}
+
+ )
+ ) : (
+
+
+
+
+ #
+ Name
+ Phone
+ Email
+ Bookings
+ Actions
+
+
+
+ {isLoading && }
+ {rows.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No customers to show
+
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {index + 1}
+
+
+
+
+
+
+ {custName(row)}
+
+
+
+
+
+ {custPhone(row)}
+
+
+
+
+ {custEmail(row)}
+
+
+ {custBookings(row)}
+
+
+ openEdit(row)} sx={{ color: BRAND }}>
+
+
+
+
+
+ ))
+ )}
+
+
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default Customers;
diff --git a/src/pages/nearle/exceptions/exceptions.js b/src/pages/nearle/exceptions/exceptions.js
new file mode 100644
index 0000000..b09b00a
--- /dev/null
+++ b/src/pages/nearle/exceptions/exceptions.js
@@ -0,0 +1,371 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Autocomplete,
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import { MdWarningAmber, MdAdd, MdCheckCircleOutline } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
+import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+const DT = {
+ radiusCard: 14,
+ radiusField: 10,
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ borderHover: '#cbd5e1',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const ring = (c) => a(c, '26');
+const BRAND = '#C01227';
+
+const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
+const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
+const SEVERITY_COLOR = { Low: '#94a3b8', Medium: '#f59e0b', High: '#ef4444', Critical: '#7f1d1d' };
+
+const emptyForm = { consignmentid: '', tripsheetid: '', hub: null, exceptiontype: 'Damaged', severity: 'Medium', description: '' };
+
+const Exceptions = () => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const queryClient = useQueryClient();
+ const [searchword, setSearchword] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(emptyForm);
+ const [resolveRow, setResolveRow] = useState(null);
+ const [resolution, setResolution] = useState('');
+ const [resolveStatus, setResolveStatus] = useState('Resolved');
+
+ const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions });
+ const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
+ const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
+
+ const rows = useMemo(() => {
+ if (!debouncedSearch) return exceptions;
+ const q = debouncedSearch.toLowerCase().trim();
+ return exceptions.filter((row) =>
+ [row.exceptiontype, row.severity, row.description, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
+ );
+ }, [exceptions, debouncedSearch]);
+
+ const stats = useMemo(() => {
+ const total = exceptions.length;
+ const open = exceptions.filter((e) => !['Resolved', 'Closed'].includes(e.status)).length;
+ const critical = exceptions.filter((e) => e.severity === 'Critical').length;
+ return { total, open, critical };
+ }, [exceptions]);
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const createMutation = useMutation({
+ mutationFn: (data) => createException(data),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Exception logged', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-exceptions'] });
+ setDialogOpen(false);
+ setForm(emptyForm);
+ } else {
+ opentoast(res.message || 'Failed to log exception');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to log exception')
+ });
+
+ const resolveMutation = useMutation({
+ mutationFn: () => updateExceptionStatus(resolveRow.exceptionid, resolveStatus, resolution),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Exception updated', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-exceptions'] });
+ setResolveRow(null);
+ setResolution('');
+ } else {
+ opentoast(res.message || 'Failed to update exception');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update exception')
+ });
+
+ const handleCreate = () => {
+ if (!form.consignmentid || !form.description) {
+ opentoast('Fill Consignment ID and Description');
+ return;
+ }
+ createMutation.mutate({
+ consignmentid: form.consignmentid,
+ tripsheetid: form.tripsheetid || undefined,
+ hubid: form.hub?.hubid,
+ exceptiontype: form.exceptiontype,
+ severity: form.severity,
+ description: form.description
+ });
+ };
+
+ return (
+ <>
+ {(isLoading || createMutation.isPending || resolveMutation.isPending) && }
+
+ } onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
+ Log Exception
+
+ }
+ />
+
+
+
+ } color={BRAND} />
+
+
+ } color="#f59e0b" />
+
+
+ } color="#ef4444" />
+
+
+
+
+
+
+ {exceptions.length} total · {rows.length} shown
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ Consignment
+ Hub
+ Type
+ Severity
+ Description
+ Status
+ Actions
+
+
+
+ {isLoading && }
+ {rows.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No exceptions logged
+
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {index + 1}
+ {row.consignmentid}
+ {hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}
+ {row.exceptiontype}
+
+
+ {row.severity}
+
+
+
+
+ {row.description}
+
+
+ {row.status || 'Open'}
+
+ {!['Resolved', 'Closed'].includes(row.status) && (
+
+ setResolveRow(row)} sx={{ color: '#10b981' }}>
+
+
+
+ )}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Exceptions;
diff --git a/src/pages/nearle/hubs/hubs.js b/src/pages/nearle/hubs/hubs.js
new file mode 100644
index 0000000..e68194d
--- /dev/null
+++ b/src/pages/nearle/hubs/hubs.js
@@ -0,0 +1,374 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
+import { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+const DT = {
+ radiusCard: 14,
+ radiusField: 10,
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ borderHover: '#cbd5e1',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const ring = (c) => a(c, '26');
+const BRAND = '#C01227';
+const HUB_TYPES = ['sorting_center', 'delivery_hub'];
+
+const emptyForm = {
+ hubid: null,
+ hubname: '',
+ hubtype: 'delivery_hub',
+ applocationid: '',
+ contactno: '',
+ address: '',
+ latitude: '',
+ longitude: '',
+ pincode: '',
+ status: 'active'
+};
+
+const Hubs = () => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const queryClient = useQueryClient();
+ const [searchword, setSearchword] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(emptyForm);
+
+ const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
+
+ const rows = useMemo(() => {
+ if (!debouncedSearch) return hubs;
+ const q = debouncedSearch.toLowerCase().trim();
+ return hubs.filter((row) => [row.hubname, row.hubtype, row.address, row.pincode].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
+ }, [hubs, debouncedSearch]);
+
+ const stats = useMemo(() => {
+ const total = hubs.length;
+ const sorting = hubs.filter((h) => h.hubtype === 'sorting_center').length;
+ const delivery = hubs.filter((h) => h.hubtype === 'delivery_hub').length;
+ return { total, sorting, delivery };
+ }, [hubs]);
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const saveMutation = useMutation({
+ mutationFn: (payload) => (payload.hubid ? updateHub(payload.hubid, payload.data) : createHub(payload.data)),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast(form.hubid ? 'Hub updated' : 'Hub created', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-hubs'] });
+ setDialogOpen(false);
+ } else {
+ opentoast(res.message || 'Failed to save hub');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save hub')
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id) => deleteHub(id),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Hub deleted', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-hubs'] });
+ } else {
+ opentoast(res.message || 'Failed to delete hub');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete hub')
+ });
+
+ const openCreate = () => {
+ setForm(emptyForm);
+ setDialogOpen(true);
+ };
+
+ const openEdit = (row) => {
+ setForm({
+ hubid: row.hubid,
+ hubname: row.hubname || '',
+ hubtype: row.hubtype || 'delivery_hub',
+ applocationid: row.applocationid ?? '',
+ contactno: row.contactno || '',
+ address: row.address || '',
+ latitude: row.latitude ?? '',
+ longitude: row.longitude ?? '',
+ pincode: row.pincode || '',
+ status: row.status || 'active'
+ });
+ setDialogOpen(true);
+ };
+
+ const handleDelete = (row) => {
+ if (window.confirm(`Delete hub "${row.hubname}"?`)) deleteMutation.mutate(row.hubid);
+ };
+
+ const handleSave = () => {
+ if (!form.hubname || !form.applocationid) {
+ opentoast('Fill Hub Name and City (applocationid)');
+ return;
+ }
+ const { hubid, ...data } = form;
+ saveMutation.mutate({
+ hubid,
+ data: {
+ ...data,
+ applocationid: Number(data.applocationid),
+ latitude: Number(data.latitude) || 0,
+ longitude: Number(data.longitude) || 0
+ }
+ });
+ };
+
+ return (
+ <>
+ {(isLoading || saveMutation.isPending || deleteMutation.isPending) && }
+
+ } onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
+ New Hub
+
+ }
+ />
+
+
+
+ } color={BRAND} />
+
+
+ } color="#0ea5e9" />
+
+
+ } color="#10b981" />
+
+
+
+
+
+
+ {hubs.length} total · {rows.length} shown
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ Hub Name
+ Type
+ City
+ Address
+ Pincode
+ Status
+ Actions
+
+
+
+ {isLoading && }
+ {rows.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No hubs to show
+
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {index + 1}
+
+
+ {row.hubname}
+
+
+ {row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}
+ {row.applocationid ?? '—'}
+ {row.address || '—'}
+ {row.pincode || '—'}
+ {row.status || '—'}
+
+
+
+ openEdit(row)} sx={{ color: BRAND }}>
+
+
+
+
+ handleDelete(row)} sx={{ color: '#ef4444' }}>
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Hubs;
diff --git a/src/pages/nearle/tripsheets/tripsheets.js b/src/pages/nearle/tripsheets/tripsheets.js
new file mode 100644
index 0000000..d9511a7
--- /dev/null
+++ b/src/pages/nearle/tripsheets/tripsheets.js
@@ -0,0 +1,328 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Autocomplete,
+ Avatar,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ InputLabel,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
+import {
+ getTripsheets,
+ createTripsheet,
+ addTripsheetItem,
+ dispatchTripsheet,
+ arriveTripsheet,
+ getHubs,
+ getVehicles,
+ getMilers
+} from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+// ============================================================================
+// Hub-to-hub transport. GET /admin/tripsheets response shape isn't documented
+// beyond the route existing (Status: Built in express-console-api.md) — field
+// bindings below are defensive. POST /admin/tripsheets body IS confirmed:
+// { sourcehubid, destinationhubid, vehicleid, driveruserid }.
+// ============================================================================
+
+const DT = {
+ radiusCard: 14,
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const BRAND = '#C01227';
+
+const emptyForm = { sourceHub: null, destinationHub: null, vehicle: null, driver: null };
+
+const Tripsheets = () => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const queryClient = useQueryClient();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(emptyForm);
+ const [itemDialogTrip, setItemDialogTrip] = useState(null);
+ const [itemConsignmentId, setItemConsignmentId] = useState('');
+
+ const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets });
+ const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
+ const { data: vehicles = [] } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
+ const { data: milers = [] } = useQuery({ queryKey: ['admin-milers-for-trips'], queryFn: getMilers });
+
+ const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
+
+ const stats = useMemo(() => {
+ const total = tripsheets.length;
+ const dispatched = tripsheets.filter((t) => String(t.status).toLowerCase().includes('dispatch')).length;
+ return { total, dispatched };
+ }, [tripsheets]);
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const createMutation = useMutation({
+ mutationFn: (data) => createTripsheet(data),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Tripsheet created', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
+ setDialogOpen(false);
+ setForm(emptyForm);
+ } else {
+ opentoast(res.message || 'Failed to create tripsheet');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create tripsheet')
+ });
+
+ const addItemMutation = useMutation({
+ mutationFn: ({ id, consignmentid }) => addTripsheetItem(id, consignmentid),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Item added', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
+ setItemConsignmentId('');
+ } else {
+ opentoast(res.message || 'Failed to add item');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to add item')
+ });
+
+ const lifecycleMutation = useMutation({
+ mutationFn: ({ action, id }) => (action === 'dispatch' ? dispatchTripsheet(id) : arriveTripsheet(id)),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Tripsheet updated', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
+ } else {
+ opentoast(res.message || 'Failed to update tripsheet');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update tripsheet')
+ });
+
+ const handleCreate = () => {
+ if (!form.sourceHub || !form.destinationHub || !form.vehicle) {
+ opentoast('Choose source hub, destination hub and vehicle');
+ return;
+ }
+ createMutation.mutate({
+ sourcehubid: form.sourceHub.hubid,
+ destinationhubid: form.destinationHub.hubid,
+ vehicleid: form.vehicle.vehicleid,
+ driveruserid: form.driver?.userid
+ });
+ };
+
+ return (
+ <>
+ {(isLoading || createMutation.isPending || lifecycleMutation.isPending) && }
+
+ } onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
+ New Tripsheet
+
+ }
+ />
+
+
+
+ } color={BRAND} />
+
+
+ } color="#14b8a6" />
+
+
+
+
+
+
+
+
+ #
+ Source Hub
+ Destination Hub
+ Status
+ Actions
+
+
+
+ {isLoading && }
+ {tripsheets.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No tripsheets to show
+
+
+
+
+ ) : (
+ tripsheets.map((row, index) => (
+
+ {index + 1}
+ {hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}
+ {hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}
+ {row.status || '—'}
+
+
+
+ setItemDialogTrip(row)} sx={{ color: BRAND }}>
+
+
+
+
+ lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
+ sx={{ color: '#14b8a6' }}
+ >
+
+
+
+
+ lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
+ sx={{ color: '#10b981' }}
+ >
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Tripsheets;
diff --git a/src/pages/nearle/vehicles/vehicles.js b/src/pages/nearle/vehicles/vehicles.js
new file mode 100644
index 0000000..80d61ca
--- /dev/null
+++ b/src/pages/nearle/vehicles/vehicles.js
@@ -0,0 +1,367 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Autocomplete,
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme
+} from '@mui/material';
+import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd } from 'react-icons/md';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import Loader from 'components/Loader';
+import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
+import PageHeader from 'components/nearle_components/PageHeader';
+import StatCard from 'components/nearle_components/StatCard';
+import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
+import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi';
+import { enqueueSnackbar } from 'notistack';
+
+const DT = {
+ radiusCard: 14,
+ radiusField: 10,
+ textPrimary: '#0f172a',
+ textSecondary: '#64748b',
+ textMuted: '#94a3b8',
+ borderSubtle: '#e2e8f0',
+ borderHover: '#cbd5e1',
+ divider: '#f1f5f9',
+ surface: '#ffffff',
+ surfaceAlt: '#f8fafc'
+};
+const a = (c, suffix) => `${c}${suffix}`;
+const soft = (c) => a(c, '18');
+const ring = (c) => a(c, '26');
+const BRAND = '#C01227';
+const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
+
+const emptyForm = {
+ vehicleid: null,
+ vehicleno: '',
+ vehicletype: 'Bike',
+ maxweight: '',
+ maxvolume: '',
+ partner: null,
+ batterypercentage: '',
+ status: 'active'
+};
+
+const Vehicles = () => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
+ const queryClient = useQueryClient();
+ const [searchword, setSearchword] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(emptyForm);
+
+ const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
+ const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
+ const partnerMap = useMemo(() => new Map((partners || []).map((p) => [p.partnerid, p])), [partners]);
+
+ const rows = useMemo(() => {
+ if (!debouncedSearch) return vehicles;
+ const q = debouncedSearch.toLowerCase().trim();
+ return vehicles.filter((row) => [row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
+ }, [vehicles, debouncedSearch]);
+
+ const stats = useMemo(() => {
+ const total = vehicles.length;
+ const active = vehicles.filter((v) => String(v.status).toLowerCase() === 'active').length;
+ const lowBattery = vehicles.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20).length;
+ return { total, active, lowBattery };
+ }, [vehicles]);
+
+ const opentoast = (message, variant = 'error') =>
+ enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
+
+ const saveMutation = useMutation({
+ mutationFn: (payload) => (payload.vehicleid ? updateVehicle(payload.vehicleid, payload.data) : createVehicle(payload.data)),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast(form.vehicleid ? 'Vehicle updated' : 'Vehicle created', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-vehicles'] });
+ setDialogOpen(false);
+ } else {
+ opentoast(res.message || 'Failed to save vehicle');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save vehicle')
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id) => deleteVehicle(id),
+ onSuccess: (res) => {
+ if (res.success) {
+ opentoast('Vehicle deleted', 'success');
+ queryClient.invalidateQueries({ queryKey: ['admin-vehicles'] });
+ } else {
+ opentoast(res.message || 'Failed to delete vehicle');
+ }
+ },
+ onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete vehicle')
+ });
+
+ const openCreate = () => {
+ setForm(emptyForm);
+ setDialogOpen(true);
+ };
+
+ const openEdit = (row) => {
+ setForm({
+ vehicleid: row.vehicleid,
+ vehicleno: row.vehicleno || '',
+ vehicletype: row.vehicletype || 'Bike',
+ maxweight: row.maxweight ?? '',
+ maxvolume: row.maxvolume ?? '',
+ partner: partnerMap.get(row.partnerid) || null,
+ batterypercentage: row.batterypercentage ?? '',
+ status: row.status || 'active'
+ });
+ setDialogOpen(true);
+ };
+
+ const handleDelete = (row) => {
+ if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid);
+ };
+
+ const handleSave = () => {
+ if (!form.vehicleno) {
+ opentoast('Fill Vehicle No');
+ return;
+ }
+ const { vehicleid, partner, ...rest } = form;
+ saveMutation.mutate({
+ vehicleid,
+ data: {
+ ...rest,
+ maxweight: Number(rest.maxweight) || 0,
+ maxvolume: Number(rest.maxvolume) || 0,
+ batterypercentage: Number(rest.batterypercentage) || 0,
+ partnerid: partner?.partnerid
+ }
+ });
+ };
+
+ return (
+ <>
+ {(isLoading || saveMutation.isPending || deleteMutation.isPending) && }
+
+ } onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
+ New Vehicle
+
+ }
+ />
+
+
+
+ } color={BRAND} />
+
+
+ } color="#10b981" />
+
+
+ } color="#f59e0b" />
+
+
+
+
+
+
+ {vehicles.length} total · {rows.length} shown
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ Vehicle No
+ Type
+ Partner
+ Max Weight
+ Battery
+ Status
+ Actions
+
+
+
+ {isLoading && }
+ {rows.length === 0 && !isLoading ? (
+
+
+
+
+
+
+
+ No vehicles to show
+
+
+
+
+ ) : (
+ rows.map((row, index) => (
+
+ {index + 1}
+
+
+ {row.vehicleno}
+
+
+ {row.vehicletype || '—'}
+ {partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}
+ {row.maxweight ?? '—'}
+ {row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}
+ {row.status || '—'}
+
+
+
+ openEdit(row)} sx={{ color: BRAND }}>
+
+
+
+
+ handleDelete(row)} sx={{ color: '#ef4444' }}>
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Vehicles;