Files
doormile_console_expresscopy/src/pages/nearle/bookings/BookingDetail.js
Suriya 709a06274a feat: console Phase 2 final - live cancel and customer endpoints
- 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>
2026-07-09 11:48:45 +05:30

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;