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