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;