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,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) && <Loader />}
<PageHeader
title="Exceptions"
subtitle="Live · Lost / Damaged / Misrouted Parcels"
live
action={
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
Log Exception
</Button>
}
/>
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
<Grid item xs={4}>
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
</Grid>
<Grid item xs={4}>
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
</Grid>
<Grid item xs={4}>
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
</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 }}>
{exceptions.length} total · {rows.length} shown
</Typography>
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search exceptions (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 ? 960 : 1020 }}>
<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>Consignment</TableCell>
<TableCell>Hub</TableCell>
<TableCell>Type</TableCell>
<TableCell>Severity</TableCell>
<TableCell>Description</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 }}>
<MdWarningAmber size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No exceptions logged
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow key={row.exceptionid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
<TableCell>{index + 1}</TableCell>
<TableCell>{row.consignmentid}</TableCell>
<TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell>
<TableCell>{row.exceptiontype}</TableCell>
<TableCell>
<Box
sx={{
display: 'inline-flex',
px: 1,
py: 0.375,
borderRadius: 999,
bgcolor: soft(SEVERITY_COLOR[row.severity] || '#94a3b8'),
color: SEVERITY_COLOR[row.severity] || '#94a3b8',
fontSize: 11,
fontWeight: 800
}}
>
{row.severity}
</Box>
</TableCell>
<TableCell sx={{ maxWidth: 220 }}>
<Typography variant="body2" noWrap title={row.description}>
{row.description}
</Typography>
</TableCell>
<TableCell>{row.status || 'Open'}</TableCell>
<TableCell align="right">
{!['Resolved', 'Closed'].includes(row.status) && (
<Tooltip title="Resolve / Close">
<IconButton size="small" onClick={() => setResolveRow(row)} sx={{ color: '#10b981' }}>
<MdCheckCircleOutline size={16} />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Log Exception</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Consignment ID</InputLabel>
<TextField value={form.consignmentid} onChange={(e) => setForm((f) => ({ ...f, consignmentid: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Tripsheet ID (optional)</InputLabel>
<TextField value={form.tripsheetid} onChange={(e) => setForm((f) => ({ ...f, tripsheetid: e.target.value }))} />
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Hub (optional)</InputLabel>
<Autocomplete
options={hubs || []}
getOptionLabel={(option) => option.hubname || ''}
value={form.hub}
onChange={(e, value) => setForm((f) => ({ ...f, hub: value }))}
renderInput={(params) => <TextField {...params} placeholder="Choose hub" />}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Type</InputLabel>
<Select fullWidth value={form.exceptiontype} onChange={(e) => setForm((f) => ({ ...f, exceptiontype: e.target.value }))}>
{EXCEPTION_TYPES.map((t) => (
<MenuItem key={t} value={t}>
{t.replace(/_/g, ' ')}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Stack spacing={1}>
<InputLabel>Severity</InputLabel>
<Select fullWidth value={form.severity} onChange={(e) => setForm((f) => ({ ...f, severity: e.target.value }))}>
{SEVERITIES.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</Stack>
</Grid>
<Grid item xs={12}>
<Stack spacing={1}>
<InputLabel>Description</InputLabel>
<TextField multiline minRows={2} value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Stack>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}>
Log Exception
</Button>
</DialogActions>
</Dialog>
<Dialog open={!!resolveRow} onClose={() => setResolveRow(null)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Resolve Exception</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Stack spacing={1}>
<InputLabel>Status</InputLabel>
<Select fullWidth value={resolveStatus} onChange={(e) => setResolveStatus(e.target.value)}>
<MenuItem value="Resolved">Resolved</MenuItem>
<MenuItem value="Closed">Closed</MenuItem>
</Select>
</Stack>
<Stack spacing={1}>
<InputLabel>Resolution Notes</InputLabel>
<TextField multiline minRows={2} value={resolution} onChange={(e) => setResolution(e.target.value)} />
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setResolveRow(null)}>Cancel</Button>
<Button variant="contained" onClick={() => resolveMutation.mutate()} sx={{ bgcolor: BRAND }}>
Save
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Exceptions;