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.
This commit is contained in:
367
src/pages/nearle/vehicles/vehicles.js
Normal file
367
src/pages/nearle/vehicles/vehicles.js
Normal file
@@ -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) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Vehicles"
|
||||
subtitle="Live · Fleet Directory"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
New Vehicle
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Active" value={stats.active} icon={<MdDirectionsCar size={20} />} color="#10b981" />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color="#f59e0b" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ mt: { xs: 1.5, md: 2 }, p: { xs: 1, md: 1.5 }, borderTopLeftRadius: DT.radiusCard / 8, borderTopRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, borderBottom: 0, background: '#fff' }}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}>
|
||||
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
||||
{vehicles.length} total · {rows.length} shown
|
||||
</Typography>
|
||||
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
|
||||
<DebounceSearchBar
|
||||
value={searchword}
|
||||
onChange={setSearchword}
|
||||
onDebouncedChange={setDebouncedSearch}
|
||||
placeholder="Search vehicles (ctrl+k)"
|
||||
sx={{
|
||||
m: 0,
|
||||
width: '100%',
|
||||
borderRadius: DT.radiusField + 'px',
|
||||
bgcolor: DT.surface,
|
||||
'& fieldset': { borderColor: DT.borderSubtle, borderWidth: 1 },
|
||||
'&:hover fieldset': { borderColor: DT.borderHover },
|
||||
'&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 1.5 },
|
||||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={0} sx={{ borderBottomLeftRadius: DT.radiusCard / 8, borderBottomRightRadius: DT.radiusCard / 8, border: '1px solid', borderColor: DT.borderSubtle, overflow: 'hidden', background: '#fff' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 900 : 960 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { backgroundColor: DT.surfaceAlt, color: DT.textSecondary, fontSize: 11, fontWeight: 800, letterSpacing: 0.6, textTransform: 'uppercase', whiteSpace: 'nowrap', borderBottom: `1px solid ${DT.borderSubtle}` } }}>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Vehicle No</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Partner</TableCell>
|
||||
<TableCell align="center">Max Weight</TableCell>
|
||||
<TableCell align="center">Battery</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={6} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdDirectionsCar size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No vehicles to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.vehicleid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{row.vehicleno}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{row.vehicletype || '—'}</TableCell>
|
||||
<TableCell>{partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}</TableCell>
|
||||
<TableCell align="center">{row.maxweight ?? '—'}</TableCell>
|
||||
<TableCell align="center">{row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Edit vehicle">
|
||||
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
|
||||
<MdEdit size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete vehicle">
|
||||
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
|
||||
<MdDeleteOutline size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{form.vehicleid ? 'Edit Vehicle' : 'New Vehicle'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle No</InputLabel>
|
||||
<TextField value={form.vehicleno} onChange={(e) => setForm((f) => ({ ...f, vehicleno: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle Type</InputLabel>
|
||||
<Select fullWidth value={form.vehicletype} onChange={(e) => setForm((f) => ({ ...f, vehicletype: e.target.value }))}>
|
||||
{VEHICLE_TYPES.map((v) => (
|
||||
<MenuItem key={v} value={v}>
|
||||
{v}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Partner</InputLabel>
|
||||
<Autocomplete
|
||||
options={partners || []}
|
||||
getOptionLabel={(option) => option.partnername || ''}
|
||||
value={form.partner}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, partner: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose partner" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="inactive">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Max Weight (kg)</InputLabel>
|
||||
<TextField type="number" value={form.maxweight} onChange={(e) => setForm((f) => ({ ...f, maxweight: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Max Volume</InputLabel>
|
||||
<TextField type="number" value={form.maxvolume} onChange={(e) => setForm((f) => ({ ...f, maxvolume: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Battery %</InputLabel>
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.batterypercentage}
|
||||
onChange={(e) => setForm((f) => ({ ...f, batterypercentage: e.target.value }))}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
|
||||
{form.vehicleid ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Vehicles;
|
||||
Reference in New Issue
Block a user