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:
2026-08-06 19:26:42 +05:30
parent 59f31c8adf
commit d144ff71a9
7 changed files with 2519 additions and 0 deletions

View File

@@ -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) && <Loader />}
<PageHeader
title="Hubs"
subtitle="Live · Sorting Centers & Delivery Hubs"
live
action={
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
New Hub
</Button>
}
/>
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
<Grid item xs={4}>
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
</Grid>
<Grid item xs={4}>
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color="#0ea5e9" />
</Grid>
<Grid item xs={4}>
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color="#10b981" />
</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 }}>
{hubs.length} total · {rows.length} shown
</Typography>
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search hubs (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>Hub Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>City</TableCell>
<TableCell>Address</TableCell>
<TableCell>Pincode</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 }}>
<MdOutlineWarehouse size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No hubs to show
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow key={row.hubid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{row.hubname}
</Typography>
</TableCell>
<TableCell>{row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}</TableCell>
<TableCell>{row.applocationid ?? '—'}</TableCell>
<TableCell>{row.address || '—'}</TableCell>
<TableCell>{row.pincode || '—'}</TableCell>
<TableCell>{row.status || '—'}</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Edit hub">
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
<MdEdit size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Delete hub">
<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.hubid ? 'Edit Hub' : 'New Hub'}</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Hub Name</InputLabel>
<TextField value={form.hubname} onChange={(e) => setForm((f) => ({ ...f, hubname: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Type</InputLabel>
<Select fullWidth value={form.hubtype} onChange={(e) => setForm((f) => ({ ...f, hubtype: e.target.value }))}>
{HUB_TYPES.map((t) => (
<MenuItem key={t} value={t}>
{t === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>City (applocationid)</InputLabel>
<TextField
type="number"
value={form.applocationid}
onChange={(e) => setForm((f) => ({ ...f, applocationid: e.target.value }))}
helperText="Read the correct id from GET /admin/hubs rather than guessing"
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Contact No</InputLabel>
<TextField value={form.contactno} onChange={(e) => setForm((f) => ({ ...f, contactno: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Address</InputLabel>
<TextField value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={4}>
<Stack spacing={1}>
<InputLabel>Pincode</InputLabel>
<TextField value={form.pincode} onChange={(e) => setForm((f) => ({ ...f, pincode: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={4}>
<Stack spacing={1}>
<InputLabel>Latitude</InputLabel>
<TextField type="number" value={form.latitude} onChange={(e) => setForm((f) => ({ ...f, latitude: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={4}>
<Stack spacing={1}>
<InputLabel>Longitude</InputLabel>
<TextField type="number" value={form.longitude} onChange={(e) => setForm((f) => ({ ...f, longitude: e.target.value }))} />
</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>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
{form.hubid ? 'Save Changes' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Hubs;