- cancelOrder/cancelDeliveryAPI: real POST /admin/bookings/:id/cancel - getallcustomers/getcustomersummary: real /admin/customers, aliasing appcustomerid -> userid for existing call sites - BookingDetail: confirmed field names from live backend, cancel button now hits the live endpoint - customers.js: table columns show Name/Phone/Email/Total Bookings/Joined using appcustomerid/name/phone/email/totalbookings/createdat Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
309 lines
13 KiB
JavaScript
309 lines
13 KiB
JavaScript
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { Avatar, Box, Button, Chip, Grid, Paper, Stack, Step, StepLabel, Stepper, Typography, useMediaQuery } from '@mui/material';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
MdArrowBack,
|
|
MdLocalShipping,
|
|
MdPerson,
|
|
MdPhone,
|
|
MdLocationOn,
|
|
MdTwoWheeler,
|
|
MdStar,
|
|
MdOutlineSmartToy,
|
|
MdOutlineAssignmentInd,
|
|
MdOutlineCancel
|
|
} from 'react-icons/md';
|
|
import Loader from 'components/Loader';
|
|
import { OpenToast } from 'components/third-party/OpenToast';
|
|
import { getorderdetails, autoAssignBooking, cancelOrder } from 'pages/api/api';
|
|
|
|
const DT = {
|
|
radiusCard: 16,
|
|
textPrimary: '#0f172a',
|
|
textSecondary: '#64748b',
|
|
textMuted: '#94a3b8',
|
|
borderSubtle: '#e2e8f0',
|
|
divider: '#f1f5f9',
|
|
surface: '#ffffff',
|
|
surfaceAlt: '#f8fafc'
|
|
};
|
|
const a = (c, suffix) => `${c}${suffix}`;
|
|
const tint = (c) => a(c, '08');
|
|
const soft = (c) => a(c, '18');
|
|
const edge = (c) => a(c, '55');
|
|
const BRAND = '#C01227';
|
|
|
|
// Ordered status progression used for the stepper. Doormile's real status
|
|
// vocabulary — Assignment_Failed and Cancelled are terminal/off-path states
|
|
// shown separately rather than as stepper nodes.
|
|
const STATUS_STEPS = ['Pending_Pickup', 'Miler_Assigned', 'Pickup_Scheduled', 'At_Customer', 'Picked_Up', 'At_Hub', 'Delivered'];
|
|
const STEP_LABELS = {
|
|
Pending_Pickup: 'Pending Pickup',
|
|
Miler_Assigned: 'Miler Assigned',
|
|
Pickup_Scheduled: 'Pickup Scheduled',
|
|
At_Customer: 'At Customer',
|
|
Picked_Up: 'Picked Up',
|
|
At_Hub: 'At Hub',
|
|
Delivered: 'Delivered'
|
|
};
|
|
|
|
const InfoRow = ({ icon: Icon, label, value, color = BRAND }) => (
|
|
<Stack direction="row" alignItems="flex-start" spacing={1.5}>
|
|
<Avatar sx={{ width: 32, height: 32, bgcolor: soft(color), color }}>
|
|
<Icon size={16} />
|
|
</Avatar>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
|
{label}
|
|
</Typography>
|
|
<Typography sx={{ fontWeight: 600, color: DT.textPrimary, wordBreak: 'break-word' }}>{value || '—'}</Typography>
|
|
</Box>
|
|
</Stack>
|
|
);
|
|
|
|
const BookingDetail = () => {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading, isError } = useQuery({
|
|
queryKey: ['bookingDetail', id],
|
|
queryFn: () => getorderdetails(id),
|
|
enabled: Boolean(id)
|
|
});
|
|
|
|
// Confirmed shape (booking id 5): bookingid, bookingreference, status,
|
|
// pickupaddress, deliveryaddress, assignedmileruserid, createdat,
|
|
// bookingparcels (nested parcels), serviceoptions, payments.
|
|
const booking = data?.data || data || {};
|
|
|
|
const reassignMutation = useMutation({
|
|
mutationFn: () => autoAssignBooking(id),
|
|
onSuccess: () => {
|
|
OpenToast('Reassignment triggered', 'success', 2000);
|
|
queryClient.invalidateQueries({ queryKey: ['bookingDetail', id] });
|
|
},
|
|
onError: (err) => OpenToast(err.response?.data?.message || err.message, 'error', 3000)
|
|
});
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: () => cancelOrder(id),
|
|
onSuccess: () => {
|
|
OpenToast('Booking cancelled', 'success', 2000);
|
|
queryClient.invalidateQueries({ queryKey: ['bookingDetail', id] });
|
|
},
|
|
onError: (err) => OpenToast(err.response?.data?.message || err.message, 'error', 3000)
|
|
});
|
|
|
|
if (isLoading) return <Loader />;
|
|
|
|
const status = booking.status || 'Pending_Pickup';
|
|
const isTerminal = ['cancelled', 'assignment_failed'].includes(String(status).toLowerCase());
|
|
const activeStepIdx = STATUS_STEPS.indexOf(status);
|
|
const parcels = booking.bookingparcels || booking.parcels || [];
|
|
const miler = booking.miler || booking.assignedmiler || null;
|
|
const agentDecision = booking.agent_decision_id ? booking.agentdecision || booking.agent_decision : null;
|
|
|
|
return (
|
|
<>
|
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
|
|
<Button startIcon={<MdArrowBack size={16} />} onClick={() => navigate('/nearle/orders')} sx={{ color: DT.textSecondary, textTransform: 'none', fontWeight: 700 }}>
|
|
Back to Bookings
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: { xs: 2, md: 3 },
|
|
borderRadius: `${DT.radiusCard}px`,
|
|
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint('#D35968')} 100%)`,
|
|
border: '1px solid',
|
|
borderColor: DT.borderSubtle,
|
|
mb: 2
|
|
}}
|
|
>
|
|
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }} spacing={2}>
|
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
|
<Avatar sx={{ width: 48, height: 48, bgcolor: BRAND, color: '#fff' }}>
|
|
<MdLocalShipping size={24} />
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h3">{booking.bookingreference || `Booking #${id}`}</Typography>
|
|
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
|
|
{booking.createdat ? new Date(booking.createdat).toLocaleString() : '—'}
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
<Chip
|
|
label={STEP_LABELS[status] || status}
|
|
sx={{ fontWeight: 800, bgcolor: isTerminal ? soft('#ef4444') : soft(BRAND), color: isTerminal ? '#ef4444' : BRAND, border: `1px solid ${edge(isTerminal ? '#ef4444' : BRAND)}` }}
|
|
/>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{isError && (
|
|
<Paper elevation={0} sx={{ p: 2, mb: 2, borderRadius: 2, border: `1px solid ${edge('#ef4444')}`, bgcolor: tint('#ef4444') }}>
|
|
<Typography sx={{ color: '#ef4444', fontWeight: 600 }}>Could not load full booking details — showing whatever came back.</Typography>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Status timeline */}
|
|
{!isTerminal && (
|
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 3 }, mb: 2, borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff' }}>
|
|
<Stepper activeStep={activeStepIdx} alternativeLabel={!isMobile} orientation={isMobile ? 'vertical' : 'horizontal'}>
|
|
{STATUS_STEPS.map((s) => (
|
|
<Step key={s}>
|
|
<StepLabel
|
|
sx={{
|
|
'& .MuiStepIcon-root.Mui-active': { color: BRAND },
|
|
'& .MuiStepIcon-root.Mui-completed': { color: BRAND }
|
|
}}
|
|
>
|
|
{STEP_LABELS[s]}
|
|
</StepLabel>
|
|
</Step>
|
|
))}
|
|
</Stepper>
|
|
</Paper>
|
|
)}
|
|
|
|
<Grid container spacing={2.5}>
|
|
<Grid item xs={12} md={6}>
|
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 2.5 }, borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', height: '100%' }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
|
Customer & Pickup
|
|
</Typography>
|
|
<Stack spacing={2}>
|
|
<InfoRow icon={MdPerson} label="Customer" value={booking.customername || booking.customer?.name} />
|
|
<InfoRow icon={MdPhone} label="Phone" value={booking.customerphone || booking.customer?.phone} />
|
|
<InfoRow icon={MdLocationOn} label="Pickup Address" value={booking.pickupaddress} color="#0ea5e9" />
|
|
</Stack>
|
|
</Paper>
|
|
</Grid>
|
|
<Grid item xs={12} md={6}>
|
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 2.5 }, borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', height: '100%' }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
|
Delivery
|
|
</Typography>
|
|
<Stack spacing={2}>
|
|
<InfoRow icon={MdLocationOn} label="Delivery Address" value={booking.deliveryaddress} color="#10b981" />
|
|
</Stack>
|
|
</Paper>
|
|
</Grid>
|
|
|
|
<Grid item xs={12} md={6}>
|
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 2.5 }, borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', height: '100%' }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
|
Assigned Miler
|
|
</Typography>
|
|
{booking.assignedmileruserid || miler ? (
|
|
<Stack direction="row" alignItems="center" spacing={1.5}>
|
|
<Avatar sx={{ width: 44, height: 44, bgcolor: soft('#8b5cf6'), color: '#8b5cf6' }}>
|
|
<MdTwoWheeler size={22} />
|
|
</Avatar>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
{miler?.displayname || `Miler #${booking.assignedmileruserid}`}
|
|
</Typography>
|
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mt: 0.25 }}>
|
|
{miler?.phone && (
|
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
{miler.phone}
|
|
</Typography>
|
|
)}
|
|
{miler?.rating != null && (
|
|
<Stack direction="row" alignItems="center" spacing={0.25}>
|
|
<MdStar size={13} style={{ color: '#f59e0b' }} />
|
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
{miler.rating}
|
|
</Typography>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
) : (
|
|
<Typography variant="body2" sx={{ color: DT.textMuted }}>
|
|
No miler assigned yet.
|
|
</Typography>
|
|
)}
|
|
</Paper>
|
|
</Grid>
|
|
|
|
<Grid item xs={12} md={6}>
|
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 2.5 }, borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', height: '100%' }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
|
Parcel Details
|
|
</Typography>
|
|
{parcels.length === 0 ? (
|
|
<Typography variant="body2" sx={{ color: DT.textMuted }}>
|
|
No parcel details available.
|
|
</Typography>
|
|
) : (
|
|
<Stack spacing={1}>
|
|
{parcels.map((p, i) => (
|
|
<Stack key={i} direction="row" justifyContent="space-between" sx={{ p: 1, borderRadius: 1.5, bgcolor: DT.surfaceAlt }}>
|
|
<Typography variant="body2">{p.description || p.name || `Parcel ${i + 1}`}</Typography>
|
|
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
|
|
{p.weight ? `${p.weight}kg` : ''}
|
|
</Typography>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Paper>
|
|
</Grid>
|
|
|
|
{agentDecision && (
|
|
<Grid item xs={12}>
|
|
<Paper elevation={0} sx={{ p: { xs: 2, md: 2.5 }, borderRadius: `${DT.radiusCard}px`, border: `1px solid ${edge('#6366f1')}`, background: tint('#6366f1') }}>
|
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
|
<Avatar sx={{ width: 32, height: 32, bgcolor: soft('#6366f1'), color: '#6366f1' }}>
|
|
<MdOutlineSmartToy size={16} />
|
|
</Avatar>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: '#6366f1' }}>
|
|
AI Assignment Reasoning
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: DT.textPrimary }}>
|
|
{agentDecision.reasoning || agentDecision.reason || JSON.stringify(agentDecision)}
|
|
</Typography>
|
|
</Paper>
|
|
</Grid>
|
|
)}
|
|
</Grid>
|
|
|
|
<Stack direction="row" spacing={1.5} justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
|
{!isTerminal && (
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<MdOutlineAssignmentInd size={16} />}
|
|
disabled={reassignMutation.isLoading}
|
|
onClick={() => reassignMutation.mutate()}
|
|
sx={{ bgcolor: '#6366f1', '&:hover': { bgcolor: '#4f46e5' } }}
|
|
>
|
|
Reassign Miler
|
|
</Button>
|
|
)}
|
|
{!isTerminal && (
|
|
<Button
|
|
variant="outlined"
|
|
color="error"
|
|
startIcon={<MdOutlineCancel size={16} />}
|
|
disabled={cancelMutation.isLoading}
|
|
onClick={() => cancelMutation.mutate()}
|
|
>
|
|
Cancel Booking
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default BookingDetail;
|