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:
374
src/pages/nearle/hubs/hubs.js
Normal file
374
src/pages/nearle/hubs/hubs.js
Normal 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;
|
||||
Reference in New Issue
Block a user