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:
328
src/pages/nearle/tripsheets/tripsheets.js
Normal file
328
src/pages/nearle/tripsheets/tripsheets.js
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import {
|
||||
getTripsheets,
|
||||
createTripsheet,
|
||||
addTripsheetItem,
|
||||
dispatchTripsheet,
|
||||
arriveTripsheet,
|
||||
getHubs,
|
||||
getVehicles,
|
||||
getMilers
|
||||
} from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
// ============================================================================
|
||||
// Hub-to-hub transport. GET /admin/tripsheets response shape isn't documented
|
||||
// beyond the route existing (Status: Built in express-console-api.md) — field
|
||||
// bindings below are defensive. POST /admin/tripsheets body IS confirmed:
|
||||
// { sourcehubid, destinationhubid, vehicleid, driveruserid }.
|
||||
// ============================================================================
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
borderSubtle: '#e2e8f0',
|
||||
divider: '#f1f5f9',
|
||||
surface: '#ffffff',
|
||||
surfaceAlt: '#f8fafc'
|
||||
};
|
||||
const a = (c, suffix) => `${c}${suffix}`;
|
||||
const soft = (c) => a(c, '18');
|
||||
const BRAND = '#C01227';
|
||||
|
||||
const emptyForm = { sourceHub: null, destinationHub: null, vehicle: null, driver: null };
|
||||
|
||||
const Tripsheets = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [itemDialogTrip, setItemDialogTrip] = useState(null);
|
||||
const [itemConsignmentId, setItemConsignmentId] = useState('');
|
||||
|
||||
const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets });
|
||||
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
const { data: vehicles = [] } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
|
||||
const { data: milers = [] } = useQuery({ queryKey: ['admin-milers-for-trips'], queryFn: getMilers });
|
||||
|
||||
const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = tripsheets.length;
|
||||
const dispatched = tripsheets.filter((t) => String(t.status).toLowerCase().includes('dispatch')).length;
|
||||
return { total, dispatched };
|
||||
}, [tripsheets]);
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => createTripsheet(data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Tripsheet created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to create tripsheet');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create tripsheet')
|
||||
});
|
||||
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: ({ id, consignmentid }) => addTripsheetItem(id, consignmentid),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Item added', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
|
||||
setItemConsignmentId('');
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to add item');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to add item')
|
||||
});
|
||||
|
||||
const lifecycleMutation = useMutation({
|
||||
mutationFn: ({ action, id }) => (action === 'dispatch' ? dispatchTripsheet(id) : arriveTripsheet(id)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Tripsheet updated', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to update tripsheet');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update tripsheet')
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!form.sourceHub || !form.destinationHub || !form.vehicle) {
|
||||
opentoast('Choose source hub, destination hub and vehicle');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
sourcehubid: form.sourceHub.hubid,
|
||||
destinationhubid: form.destinationHub.hubid,
|
||||
vehicleid: form.vehicle.vehicleid,
|
||||
driveruserid: form.driver?.userid
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Tripsheets"
|
||||
subtitle="Live · Hub-to-Hub Transport"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
New Tripsheet
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={6}>
|
||||
<StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: DT.borderSubtle, borderRadius: DT.radiusCard / 8 + 'px', overflow: 'hidden', background: '#fff' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 860 : 920 }}>
|
||||
<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>Source Hub</TableCell>
|
||||
<TableCell>Destination Hub</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={3} />}
|
||||
{tripsheets.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdOutlineLocalShipping size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No tripsheets to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tripsheets.map((row, index) => (
|
||||
<TableRow key={row.tripsheetid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
|
||||
<TableCell>{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Add item">
|
||||
<IconButton size="small" onClick={() => setItemDialogTrip(row)} sx={{ color: BRAND }}>
|
||||
<MdPlaylistAdd size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Dispatch">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
|
||||
sx={{ color: '#14b8a6' }}
|
||||
>
|
||||
<MdOutlineFlightTakeoff size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Arrive">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
|
||||
sx={{ color: '#10b981' }}
|
||||
>
|
||||
<MdOutlineFlightLand size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>New Tripsheet</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Source Hub</InputLabel>
|
||||
<Autocomplete
|
||||
options={hubs || []}
|
||||
getOptionLabel={(option) => option.hubname || ''}
|
||||
value={form.sourceHub}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, sourceHub: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose source hub" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Destination Hub</InputLabel>
|
||||
<Autocomplete
|
||||
options={hubs || []}
|
||||
getOptionLabel={(option) => option.hubname || ''}
|
||||
value={form.destinationHub}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, destinationHub: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose destination hub" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle</InputLabel>
|
||||
<Autocomplete
|
||||
options={vehicles || []}
|
||||
getOptionLabel={(option) => option.vehicleno || ''}
|
||||
value={form.vehicle}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, vehicle: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose vehicle" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Driver (optional)</InputLabel>
|
||||
<Autocomplete
|
||||
options={milers || []}
|
||||
getOptionLabel={(option) => option.displayname || option.authname || ''}
|
||||
value={form.driver}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, driver: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose driver" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!itemDialogTrip} onClose={() => setItemDialogTrip(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Add Item to Tripsheet</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
<InputLabel>Consignment ID</InputLabel>
|
||||
<TextField value={itemConsignmentId} onChange={(e) => setItemConsignmentId(e.target.value)} autoFocus />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setItemDialogTrip(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!itemConsignmentId}
|
||||
onClick={() => addItemMutation.mutate({ id: itemDialogTrip.tripsheetid, consignmentid: itemConsignmentId })}
|
||||
sx={{ bgcolor: BRAND }}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tripsheets;
|
||||
Reference in New Issue
Block a user