From d144ff71a961100c4c64223889c982cfc331930c Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 6 Aug 2026 19:26:42 +0530 Subject: [PATCH] Add console pages for previously plumbing-only Doormile API resources Hubs, Vehicles, App Users, Tripsheets, Exceptions, and Competitive Intel had full CRUD support in doormileApi.js but no UI anywhere in the console. B2C Customers had neither. Field bindings for Pricing/Vehicles/Tripsheets/ Exceptions/Customers are confirmed against live read-only API responses; App Users' write body and both Competitive Intel resources (no documented schema at all for competitor-branches/carrier-pricing) are still best-effort guesses and unverified against a real write. Routed under a new "Fleet & Ops" sidebar group except Customers, which sits at the top level next to Pricing. --- src/pages/nearle/appUsers/appUsers.js | 335 +++++++++++++++ .../competitiveIntel/competitiveIntel.js | 386 ++++++++++++++++++ src/pages/nearle/customers/customers.js | 358 ++++++++++++++++ src/pages/nearle/exceptions/exceptions.js | 371 +++++++++++++++++ src/pages/nearle/hubs/hubs.js | 374 +++++++++++++++++ src/pages/nearle/tripsheets/tripsheets.js | 328 +++++++++++++++ src/pages/nearle/vehicles/vehicles.js | 367 +++++++++++++++++ 7 files changed, 2519 insertions(+) create mode 100644 src/pages/nearle/appUsers/appUsers.js create mode 100644 src/pages/nearle/competitiveIntel/competitiveIntel.js create mode 100644 src/pages/nearle/customers/customers.js create mode 100644 src/pages/nearle/exceptions/exceptions.js create mode 100644 src/pages/nearle/hubs/hubs.js create mode 100644 src/pages/nearle/tripsheets/tripsheets.js create mode 100644 src/pages/nearle/vehicles/vehicles.js 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' }}> + + + + + + + )) + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="xs" fullWidth> + {form.id ? 'Edit User' : 'New Staff User'} + + + + + Name + setForm((f) => ({ ...f, first_name: e.target.value }))} /> + + + + + Email + setForm((f) => ({ ...f, email: e.target.value }))} /> + + + + + Phone + setForm((f) => ({ ...f, phone: e.target.value }))} /> + + + + + {form.id ? 'New Password (leave blank to keep unchanged)' : 'Password'} + setForm((f) => ({ ...f, password: e.target.value }))} /> + + + + + Role + + + + + + Status + + + + + + + + + + + + ); +}; + +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' }}> + + + + + + + )) + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="xs" fullWidth> + + {tab === 'branches' ? (branchForm.id ? 'Edit Branch' : 'New Competitor Branch') : carrierForm.id ? 'Edit Carrier Rate' : 'New Carrier Rate'} + + + {tab === 'branches' ? ( + + + + Branch Name + setBranchForm((f) => ({ ...f, branchname: e.target.value }))} /> + + + + + Competitor Name + setBranchForm((f) => ({ ...f, competitorname: e.target.value }))} /> + + + + + City + setBranchForm((f) => ({ ...f, city: e.target.value }))} /> + + + + + Address + setBranchForm((f) => ({ ...f, address: e.target.value }))} /> + + + + ) : ( + + + + Carrier Name + setCarrierForm((f) => ({ ...f, carriername: e.target.value }))} /> + + + + + Vehicle Type + setCarrierForm((f) => ({ ...f, vehicletype: e.target.value }))} /> + + + + + Base Price + setCarrierForm((f) => ({ ...f, baseprice: e.target.value }))} /> + + + + + Price/Km + setCarrierForm((f) => ({ ...f, priceperkm: e.target.value }))} /> + + + + )} + + + + + + + + ); +}; + +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 }}> + + + + + + )) + )} + +
+
+ )} +
+ + setEditRow(null)} maxWidth="xs" fullWidth> + Edit Customer + + + + Name + setForm((f) => ({ ...f, name: e.target.value }))} /> + + + Phone + setForm((f) => ({ ...f, phone: e.target.value }))} + InputProps={{ startAdornment: }} + /> + + + Email + setForm((f) => ({ ...f, email: e.target.value }))} /> + + + + + + + + + + ); +}; + +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' }}> + + + + )} + + + )) + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + Log Exception + + + + + Consignment ID + setForm((f) => ({ ...f, consignmentid: e.target.value }))} /> + + + + + Tripsheet ID (optional) + setForm((f) => ({ ...f, tripsheetid: e.target.value }))} /> + + + + + Hub (optional) + option.hubname || ''} + value={form.hub} + onChange={(e, value) => setForm((f) => ({ ...f, hub: value }))} + renderInput={(params) => } + /> + + + + + Type + + + + + + Severity + + + + + + Description + setForm((f) => ({ ...f, description: e.target.value }))} /> + + + + + + + + + + + setResolveRow(null)} maxWidth="xs" fullWidth> + Resolve Exception + + + + Status + + + + Resolution Notes + setResolution(e.target.value)} /> + + + + + + + + + + ); +}; + +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' }}> + + + + + + + )) + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + {form.hubid ? 'Edit Hub' : 'New Hub'} + + + + + Hub Name + setForm((f) => ({ ...f, hubname: e.target.value }))} /> + + + + + Type + + + + + + City (applocationid) + setForm((f) => ({ ...f, applocationid: e.target.value }))} + helperText="Read the correct id from GET /admin/hubs rather than guessing" + /> + + + + + Contact No + setForm((f) => ({ ...f, contactno: e.target.value }))} /> + + + + + Address + setForm((f) => ({ ...f, address: e.target.value }))} /> + + + + + Pincode + setForm((f) => ({ ...f, pincode: e.target.value }))} /> + + + + + Latitude + setForm((f) => ({ ...f, latitude: e.target.value }))} /> + + + + + Longitude + setForm((f) => ({ ...f, longitude: e.target.value }))} /> + + + + + Status + + + + + + + + + + + + ); +}; + +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' }} + > + + + + + + + )) + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="xs" fullWidth> + New Tripsheet + + + + + Source Hub + option.hubname || ''} + value={form.sourceHub} + onChange={(e, value) => setForm((f) => ({ ...f, sourceHub: value }))} + renderInput={(params) => } + /> + + + + + Destination Hub + option.hubname || ''} + value={form.destinationHub} + onChange={(e, value) => setForm((f) => ({ ...f, destinationHub: value }))} + renderInput={(params) => } + /> + + + + + Vehicle + option.vehicleno || ''} + value={form.vehicle} + onChange={(e, value) => setForm((f) => ({ ...f, vehicle: value }))} + renderInput={(params) => } + /> + + + + + Driver (optional) + option.displayname || option.authname || ''} + value={form.driver} + onChange={(e, value) => setForm((f) => ({ ...f, driver: value }))} + renderInput={(params) => } + /> + + + + + + + + + + + setItemDialogTrip(null)} maxWidth="xs" fullWidth> + Add Item to Tripsheet + + + Consignment ID + setItemConsignmentId(e.target.value)} autoFocus /> + + + + + + + + + ); +}; + +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' }}> + + + + + + + )) + )} + +
+
+
+ + setDialogOpen(false)} maxWidth="sm" fullWidth> + {form.vehicleid ? 'Edit Vehicle' : 'New Vehicle'} + + + + + Vehicle No + setForm((f) => ({ ...f, vehicleno: e.target.value }))} /> + + + + + Vehicle Type + + + + + + Partner + option.partnername || ''} + value={form.partner} + onChange={(e, value) => setForm((f) => ({ ...f, partner: value }))} + renderInput={(params) => } + /> + + + + + Status + + + + + + Max Weight (kg) + setForm((f) => ({ ...f, maxweight: e.target.value }))} /> + + + + + Max Volume + setForm((f) => ({ ...f, maxvolume: e.target.value }))} /> + + + + + Battery % + setForm((f) => ({ ...f, batterypercentage: e.target.value }))} + /> + + + + + + + + + + + ); +}; + +export default Vehicles;