feat: Doormile console Phase 2 complete

Part A: remaining api.js legacy functions replaced
  - fetchRidersList, cancelOrder/Delivery, changeRiderAPI
  - fetchPercentageAPI, fetchCountAPI → Doormile status counts
  - getpricinglist, fetchLocations, fetchRidersLogs, getusers
  - fetchPaymentType, getorderdetails

Part B: deliveries.js status tabs → Doormile statuses

Part C: 3 new pages
  - pages/nearle/hubs/Hubs.js (hub CRUD + miler counts)
  - pages/nearle/bookings/BookingDetail.js (booking detail + timeline)
  - pages/nearle/dashboard/Dashboard.js (live KPIs + city breakdown)

Part D: routes + menu updated
This commit is contained in:
2026-07-08 19:12:28 +05:30
parent b8f1cd5f1d
commit 1a71732396
8 changed files with 1323 additions and 520 deletions

View File

@@ -58,6 +58,13 @@ const nearle = {
icon: icons.FileDoneOutlined,
type: 'group',
children: [
{
id: 'dashboard',
title: <FormattedMessage id="dashboard" />,
type: 'item',
url: '/nearle/dashboard',
icon: icons.DashboardOutlined
},
{
id: 'dispatch',
title: <FormattedMessage id="dispatch" />,
@@ -107,6 +114,13 @@ const nearle = {
type: 'item',
url: '/nearle/riders',
icon: DirectionsBikeOutlinedIcon
},
{
id: 'hubs',
title: <FormattedMessage id="hubs" />,
type: 'item',
url: '/nearle/hubs',
icon: icons.DeploymentUnitOutlined
}
]
};

View File

@@ -34,6 +34,21 @@ export const fetchAppLocations = async () => {
return [...hubs, { locationname: 'All', applocationid: 0 }];
};
// ==============================|| fetchHubs / createHub / updateHub (hubs) ||============================== //
export const fetchHubs = async () => {
const res = await axios.get(`${process.env.REACT_APP_URL}/admin/hubs`);
return res.data?.data || [];
};
export const createHub = async (body) => {
const res = await axios.post(`${process.env.REACT_APP_URL}/admin/hubs`, body);
return res.data;
};
export const updateHub = async (id, body) => {
const res = await axios.patch(`${process.env.REACT_APP_URL}/admin/hubs/${id}`, body);
return res.data;
};
// ==============================|| fetchPercentageData (orders) ||============================== //
export const fetchPercentageData = async ({ queryKey }) => {
@@ -128,33 +143,25 @@ export const fetchOrders = async ({ pageParam = 1, queryKey }) => {
};
// ==============================|| fetchPaymentType (orders) ||============================== //
export const fetchPaymentType = async () => {
const { data } = await axios.get(`${process.env.REACT_APP_URL}/utils/getapptypes/?tag=paymentmode`);
return data.details.map((val) => ({
...val,
label: val.typename
}));
};
// No Doormile payment-types endpoint exists yet — static defaults.
export const fetchPaymentType = async () => [
{ apptypeid: 1, typename: 'Cash', label: 'Cash' },
{ apptypeid: 2, typename: 'Online', label: 'Online' },
{ apptypeid: 3, typename: 'COD', label: 'COD' }
];
// ==============================|| fetchRidersList (orders) ||============================== //
export const fetchRidersList = async ({ queryKey }) => {
try {
const [, appId] = queryKey; // Extract appId from queryKey
const { data } = await axios.get(`${process.env.REACT_APP_URL}/partners/getriders/?applocationid=${appId}`);
console.log('data', data);
const response = data?.details
? data?.details.map((val) => ({
...val,
label: `${val.firstname} ${val.lastname} | ${val.contactno}`
}))
: [];
return response;
} catch (err) {
OpenToast(err.message, 'error', 2000);
throw err; // 🔥 REQUIRED
}
export const fetchRidersList = async () => {
const res = await axios.get(`${process.env.REACT_APP_URL}/admin/milers`);
return (res.data?.data || []).map((m) => ({
...m,
userid: m.userid,
label: `${m.displayname} | ${m.phone}`,
firstname: m.displayname,
lastname: '',
contactno: m.phone
}));
};
// ==============================|| Doormile assignment (new) ||============================== //
@@ -284,13 +291,8 @@ export const notifyRider = async () => ({ success: true });
// ==============================|| cancelOrder (orders) ||============================== //
export const cancelOrder = async (orderheaderid) => {
const response = await axios.put(`${process.env.REACT_APP_URL}/orders/updateorder`, {
orderheaderid: orderheaderid,
orderstatus: 'cancelled',
cancelled: dayjs().format('YYYY-MM-DD HH:mm:ss')
});
export const cancelOrder = async (bookingid) => {
const response = await axios.patch(`${process.env.REACT_APP_URL}/admin/bookings/${bookingid}/status`, { status: 'Cancelled' });
return response.data;
};
// ==============================|| cancelMultipleOrder (orders) ||============================== //
@@ -337,81 +339,74 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
};
// ==============================|| fetchPercentageAPI (deliveries) ||============================== //
export const fetchPercentageAPI = async (appId) => {
const url = `${process.env.REACT_APP_URL}/deliveries/deliverysummary/?applocationid=${appId}`;
const response = await axios.get(url);
const data = response.data.details;
// UNVERIFIED: status param + pagesize=1-for-count-only pattern hasn't been
// confirmed against the real backend the way bookings/clients/milers were —
// implemented per explicit instruction, flag if it 404s/403s like
// /admin/customers did.
export const fetchPercentageAPI = async () => {
const statuses = ['Pending_Pickup', 'Miler_Assigned', 'Picked_Up', 'Delivered', 'Cancelled'];
const results = await Promise.all(
statuses.map((s) => axios.get(`${process.env.REACT_APP_URL}/admin/bookings`, { params: { status: s, pagesize: 1 } }))
);
const [pending, assigned, picked, delivered, cancelled] = results.map((r) => r.data?.total || 0);
const total = pending + assigned + picked + delivered + cancelled;
return {
coveredOrders: data.delivered.toString(),
cancelledOrders: data.cancelled.toString(),
uncoveredOrders: data.pending.toString(),
assignedOrders: data.accepted.toString(),
createdOrders: data.created.toString(),
closedOrders: data.delivered.toString(),
pickedOrders: data.picked.toString(),
percentage1: (Math.round((data.pending / data.total) * 100) || 0).toString(),
percentage2: (Math.round((data.accepted / data.total) * 100) || 0).toString(),
percentage3: (Math.round((data.picked / data.total) * 100) || 0).toString(),
percentage4: (Math.round((data.delivered / data.total) * 100) || 0).toString()
coveredOrders: delivered.toString(),
cancelledOrders: cancelled.toString(),
uncoveredOrders: pending.toString(),
assignedOrders: assigned.toString(),
createdOrders: total.toString(),
closedOrders: delivered.toString(),
pickedOrders: picked.toString(),
total: total.toString(),
percentage1: (Math.round((pending / total) * 100) || 0).toString(),
percentage2: (Math.round((assigned / total) * 100) || 0).toString(),
percentage3: (Math.round((picked / total) * 100) || 0).toString(),
percentage4: (Math.round((delivered / total) * 100) || 0).toString()
};
};
// ==============================|| fetchCountAPI (deliveries) ||============================== //
export const fetchCountAPI = async (appId, userid, startdate, enddate, rowsPerPage, debouncedSearch, tenantid, locationid, riderid) => {
const url =
appId == 0
? `${process.env.REACT_APP_URL}/deliveries/deliverysummary/?appuserid=${userid}&fromdate=${startdate}&todate=${enddate}`
: `${process.env.REACT_APP_URL}/deliveries/deliverysummary/?applocationid=${appId}&fromdate=${startdate}&todate=${enddate}&tenantid=${tenantid}&locationid=${locationid}&userid=${riderid}`;
const response = await axios.get(url);
const data = response.data.details;
export const fetchCountAPI = async () => {
const statuses = ['Pending_Pickup', 'Miler_Assigned', 'Pickup_Scheduled', 'At_Customer', 'Picked_Up', 'At_Hub', 'Delivered', 'Cancelled'];
const results = await Promise.all(
statuses.map((s) => axios.get(`${process.env.REACT_APP_URL}/admin/bookings`, { params: { status: s, pagesize: 1 } }))
);
const [pending, assigned, scheduled, atcustomer, picked, athub, delivered, cancelled] = results.map((r) => r.data?.total || 0);
return {
total: data.total,
uncoveredLength: data.pending,
assignedLength: data.accepted,
arrivedLength: data.arrived,
pickedLength: data.picked,
activeLength: data.active,
coveredLength: data.delivered,
cancelLength: data.cancelled,
skippedLength: data.skipped
total: pending + assigned + scheduled + atcustomer + picked + athub + delivered + cancelled,
uncoveredLength: pending,
assignedLength: assigned,
arrivedLength: scheduled,
pickedLength: picked,
activeLength: atcustomer + athub,
coveredLength: delivered,
cancelLength: cancelled,
skippedLength: 0
};
};
// ==============================|| cancelDeliveryAPI (deliveries) ||============================== //
export const cancelDeliveryAPI = async (selectedRow, cancelFeed) => {
const payload = {
deliveryid: selectedRow.deliveryid,
orderheaderid: selectedRow.orderheaderid,
orderstatus: 'cancelled',
canceltime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
feedback: cancelFeed
};
const response = await axios.put(`${process.env.REACT_APP_URL}/deliveries/updatedelivery`, payload);
export const cancelDeliveryAPI = async (selectedRow) => {
const response = await axios.patch(`${process.env.REACT_APP_URL}/admin/bookings/${selectedRow.bookingid}/status`, {
status: 'Cancelled'
});
return response.data;
};
// ==============================|| getorderdetails (deliveries) ||============================== //
export const getorderdetails = async (orderHeaderid) => {
const response = await axios.get(`${process.env.REACT_APP_URL}/orders/getorderdetails?orderheaderid=${orderHeaderid}`);
export const getorderdetails = async (bookingid) => {
const response = await axios.get(`${process.env.REACT_APP_URL}/admin/bookings/${bookingid}`);
return response.data;
};
// ==============================|| changeRiderAPI (deliveries) ||============================== //
export const changeRiderAPI = async (selectedRider, selectedRow) => {
console.log('selectedRider', selectedRider);
console.log('selectedRow', selectedRow);
return axios.put(`${process.env.REACT_APP_URL}/deliveries/updatedelivery`, {
userid: selectedRider.userid,
deliveryid: selectedRow.deliveryid,
orderheaderid: selectedRow.orderheaderid,
orderstatus: 'pending',
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss')
return axios.post(`${process.env.REACT_APP_URL}/hub/bookings/${selectedRow.bookingid}/assign-miler`, {
mileruserid: selectedRider.userid
});
};
// ==============================|| updateDeliveryAPI (deliveries) ||============================== //
@@ -465,30 +460,18 @@ export const gettenantsummary = async () => {
};
// ==============================|| getpricinglist (tenants) ||============================== //
export const getpricinglist = async ({ queryKey }) => {
const [, appId] = queryKey;
export const getpricinglist = async () => {
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/tenants/getpricinglist/?moduleid=6&applocationid=${appId}`);
return response.data.summary; // return only data, keep it clean
const response = await axios.get(`${process.env.REACT_APP_URL}/admin/pricing`);
return response.data?.data || [];
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return null; // return null for failure
OpenToast(err.message, 'error', 2000);
return [];
}
};
// ==============================|| getallpricing (clientPricing) ||============================== //
export const getallpricing = async ({ queryKey }) => {
const [, appId] = queryKey;
try {
const response = await axios.get(`${process.env.REACT_APP_URL}/utils/getallpricing/?applocationid=${appId}`);
return response.data.details || [];
} catch (err) {
const message = err.response?.data?.message || err.message || 'Something went wrong';
OpenToast(message);
return [];
}
};
export const getallpricing = getpricinglist;
// ==============================|| getcustomersummary (customers) ||============================== //
// No dedicated Doormile summary endpoint has been specified for customers —
@@ -621,35 +604,26 @@ export const fetchorderdetails = async ({ queryKey }) => {
};
// ==============================|| fetchLocations (orders summary))||============================== //
// Not needed as a separate concept in Doormile — returns the hub list instead.
export const fetchLocations = async () => {
const response = await axios.get(`${process.env.REACT_APP_URL}/partners/getpartners`);
const updatedLocations = [
...response.data.details,
{ partnername: 'All', partnerid: -1 } // Add your new object here
const response = await axios.get(`${process.env.REACT_APP_URL}/admin/hubs`);
return [
...(response.data?.data || []).map((h) => ({ ...h, partnername: h.hubname, partnerid: h.hubid })),
{ partnername: 'All', partnerid: -1 }
];
console.log('fetchLocations', updatedLocations);
return updatedLocations;
};
// ==============================|| fetchRidersLogs (RiderLogs)||============================== //
export const fetchRidersLogs = async ({ queryKey }) => {
const [appId, startdate, riderSearch = ''] = queryKey;
const riderLogsResponse = await axios.get(
`${process.env.REACT_APP_URL2}/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${startdate}&keyword=${riderSearch || ''
} `
);
console.log('fetchRidersLogs', riderLogsResponse.data.details);
return riderLogsResponse.data.details;
};
// No Doormile equivalent yet (real-time miler location stream lands with EMQX).
export const fetchRidersLogs = async () => [];
// ==============================|| getusers (viewProfile)||============================== //
export const getusers = async () => {
try {
const res = await axios.get(`${process.env.REACT_APP_URL}/users/getusers/?configid=9&userid=${userid}`);
return res.data.details;
const res = await axios.get(`${process.env.REACT_APP_URL}/admin/users`);
return res.data?.data || [];
} catch (err) {
console.log('getusers', err.message);
return [];
}
};

View File

@@ -0,0 +1,308 @@
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)
});
// Response shape is unconfirmed beyond what /admin/bookings' list rows
// return — reads defensively off `data.data` first, falling back to the
// top-level response, same pattern used for the login response mismatch.
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;

View File

@@ -0,0 +1,317 @@
import { useNavigate } from 'react-router-dom';
import { Avatar, Box, Chip, Grid, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography, Button } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import dayjs from 'dayjs';
import {
MdOutlineLocalShipping,
MdOutlinePendingActions,
MdOutlineCheckCircle,
MdTwoWheeler,
MdOutlineSmartToy,
MdDeploymentUnit,
MdArrowForward,
MdCircle
} from 'react-icons/md';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { fetchHubs } 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';
// Static list — the actual multi-agent pipeline (decide-assignment etc.) has
// no status endpoint yet, so this is presentational only, per spec.
const AGENTS = [
'Intake Agent',
'Geocoding Agent',
'Pricing Agent',
'Miler Matching Agent',
'Route Optimisation Agent',
'Notification Agent',
'Fraud Detection Agent',
'Reconciliation Agent'
];
const fetchBookingsByStatus = async (status, pagesize = 1) => {
const res = await axios.get(`${process.env.REACT_APP_URL}/admin/bookings`, { params: { status, pagesize } });
return res.data;
};
const Dashboard = () => {
const navigate = useNavigate();
const { data: activeStatusCounts, isLoading: activeLoading } = useQuery({
queryKey: ['dashboardActiveCounts'],
queryFn: async () => {
const [assigned, scheduled, atCustomer] = await Promise.all([
fetchBookingsByStatus('Miler_Assigned'),
fetchBookingsByStatus('Pickup_Scheduled'),
fetchBookingsByStatus('At_Customer')
]);
return (assigned.total || 0) + (scheduled.total || 0) + (atCustomer.total || 0);
}
});
const { data: pendingCount, isLoading: pendingLoading } = useQuery({
queryKey: ['dashboardPendingCount'],
queryFn: async () => (await fetchBookingsByStatus('Pending_Pickup')).total || 0
});
const { data: deliveredTodayCount, isLoading: deliveredLoading } = useQuery({
queryKey: ['dashboardDeliveredToday'],
queryFn: async () => (await fetchBookingsByStatus('Delivered')).total || 0
});
const { data: milers = [], isLoading: milersLoading } = useQuery({
queryKey: ['dashboardMilers'],
queryFn: async () => {
const res = await axios.get(`${process.env.REACT_APP_URL}/admin/milers`);
return res.data?.data || [];
}
});
const availableMilers = milers.filter((m) => m.availabilitystatus === 'Available').length;
const { data: hubs = [] } = useQuery({ queryKey: ['fetchHubs'], queryFn: fetchHubs });
const { data: recentBookings = [], isLoading: recentLoading } = useQuery({
queryKey: ['dashboardRecentBookings'],
queryFn: async () => {
const res = await axios.get(`${process.env.REACT_APP_URL}/admin/bookings`, { params: { pagesize: 10 } });
return res.data?.data || [];
}
});
// No `city` field is confirmed on a booking (only pickupaddress/deliveryaddress
// were ever specified) -- derives a best-effort city breakdown from hubs
// instead, since hubs do have a confirmed `city` field. Flagged rather than
// guessed at a booking-level city grouping that may not exist.
const cityRows = Object.values(
hubs.reduce((acc, h) => {
const city = h.city || 'Unknown';
if (!acc[city]) acc[city] = { city, milers: 0, hubs: 0 };
acc[city].hubs += 1;
acc[city].milers += milers.filter((m) => m.hubid === h.hubid).length;
return acc;
}, {})
);
const kpis = [
{ key: 'active', label: 'Active Bookings', color: BRAND, icon: MdOutlineLocalShipping, value: activeStatusCounts ?? 0, loading: activeLoading },
{ key: 'pending', label: 'Pending Assignment', color: '#f59e0b', icon: MdOutlinePendingActions, value: pendingCount ?? 0, loading: pendingLoading },
{ key: 'delivered', label: 'Delivered Today', color: '#10b981', icon: MdOutlineCheckCircle, value: deliveredTodayCount ?? 0, loading: deliveredLoading },
{ key: 'milers', label: 'Milers Available', color: '#0ea5e9', icon: MdTwoWheeler, value: availableMilers, loading: milersLoading }
];
return (
<>
<PageHeader title="Dashboard" subtitle="Live · Operations overview" live />
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
{kpis.map((item) => {
const Icon = item.icon;
return (
<Grid item key={item.key} xs={6} sm={3}>
<StatCard title={item.label} value={item.value} icon={<Icon size={20} />} color={item.color} loading={item.loading} />
</Grid>
);
})}
</Grid>
<Grid container spacing={2.5}>
<Grid item xs={12} md={7}>
<Paper elevation={0} sx={{ borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', overflow: 'hidden' }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ p: 2, borderBottom: `1px solid ${DT.divider}` }}>
<MdDeploymentUnit size={18} color={BRAND} />
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
City Breakdown
</Typography>
</Stack>
<TableContainer>
<Table>
<TableHead>
<TableRow sx={{ '& th': { color: DT.textSecondary, fontWeight: 800, fontSize: 11, textTransform: 'uppercase' } }}>
<TableCell>City</TableCell>
<TableCell align="center">Hubs</TableCell>
<TableCell align="center">Milers</TableCell>
<TableCell align="center">Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{cityRows.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ textAlign: 'center', py: 4, color: DT.textMuted }}>
No hub data yet.
</TableCell>
</TableRow>
)}
{cityRows.map((row) => (
<TableRow key={row.city} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` } }}>
<TableCell sx={{ fontWeight: 700 }}>{row.city}</TableCell>
<TableCell align="center">{row.hubs}</TableCell>
<TableCell align="center">{row.milers}</TableCell>
<TableCell align="center">
<Chip
size="small"
label={row.milers > 0 ? 'Active' : 'Idle'}
sx={{
bgcolor: row.milers > 0 ? tint('#10b981') : tint('#94a3b8'),
color: row.milers > 0 ? '#10b981' : '#94a3b8',
border: `1px solid ${edge(row.milers > 0 ? '#10b981' : '#94a3b8')}`,
fontWeight: 700
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Paper elevation={0} sx={{ mt: 2.5, borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', overflow: 'hidden' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ p: 2, borderBottom: `1px solid ${DT.divider}` }}>
<Stack direction="row" alignItems="center" spacing={1}>
<MdOutlineLocalShipping size={18} color={BRAND} />
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
Recent Bookings
</Typography>
</Stack>
<Button size="small" endIcon={<MdArrowForward size={14} />} onClick={() => navigate('/nearle/orders')} sx={{ textTransform: 'none', fontWeight: 700 }}>
View all
</Button>
</Stack>
<TableContainer>
<Table>
<TableHead>
<TableRow sx={{ '& th': { color: DT.textSecondary, fontWeight: 800, fontSize: 11, textTransform: 'uppercase' } }}>
<TableCell>Booking</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
</TableRow>
</TableHead>
<TableBody>
{recentLoading && (
<TableRow>
<TableCell colSpan={3} sx={{ textAlign: 'center', py: 4, color: DT.textMuted }}>
Loading
</TableCell>
</TableRow>
)}
{!recentLoading && recentBookings.length === 0 && (
<TableRow>
<TableCell colSpan={3} sx={{ textAlign: 'center', py: 4, color: DT.textMuted }}>
No bookings yet.
</TableCell>
</TableRow>
)}
{recentBookings.map((b) => (
<TableRow
key={b.bookingid}
hover
sx={{ cursor: 'pointer', '& td': { borderBottom: `1px solid ${DT.divider}` } }}
onClick={() => navigate(`/nearle/bookings/${b.bookingid}`)}
>
<TableCell sx={{ fontWeight: 700 }}>{b.bookingreference || `#${b.bookingid}`}</TableCell>
<TableCell>
<Chip size="small" label={b.status || '—'} sx={{ bgcolor: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}`, fontWeight: 700 }} />
</TableCell>
<TableCell sx={{ color: DT.textSecondary, fontSize: 13 }}>
{b.createdat ? dayjs(b.createdat).format('DD/MM/YYYY hh:mm A') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
<Grid item xs={12} md={5}>
<Paper elevation={0} sx={{ borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, background: '#fff', overflow: 'hidden' }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ p: 2, borderBottom: `1px solid ${DT.divider}` }}>
<MdOutlineSmartToy size={18} color="#6366f1" />
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
Agent System Status
</Typography>
</Stack>
<Stack spacing={0} sx={{ p: 1 }}>
{AGENTS.map((agent) => (
<Stack
key={agent}
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 1.5, py: 1.25, borderRadius: 2, '&:hover': { bgcolor: DT.surfaceAlt } }}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Avatar sx={{ width: 30, height: 30, bgcolor: soft('#6366f1'), color: '#6366f1' }}>
<MdOutlineSmartToy size={15} />
</Avatar>
<Typography variant="body2" sx={{ fontWeight: 600, color: DT.textPrimary }}>
{agent}
</Typography>
</Stack>
<Stack direction="row" alignItems="center" spacing={0.5}>
<MdCircle size={8} color="#10b981" />
<Typography variant="caption" sx={{ color: '#10b981', fontWeight: 700 }}>
Running
</Typography>
</Stack>
</Stack>
))}
</Stack>
</Paper>
<Paper
elevation={0}
component="button"
onClick={() => navigate('/nearle/hubs')}
sx={{
mt: 2.5,
width: '100%',
p: 2,
borderRadius: `${DT.radiusCard}px`,
border: `1px solid ${edge(BRAND)}`,
background: tint(BRAND),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
textAlign: 'left',
font: 'inherit'
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ bgcolor: BRAND, color: '#fff' }}>
<MdDeploymentUnit size={18} />
</Avatar>
<Box>
<Typography sx={{ fontWeight: 800, color: DT.textPrimary }}>Manage Hubs</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{hubs.length} hub{hubs.length === 1 ? '' : 's'} configured
</Typography>
</Box>
</Stack>
<MdArrowForward size={18} color={BRAND} />
</Paper>
</Grid>
</Grid>
</>
);
};
export default Dashboard;

View File

@@ -25,8 +25,6 @@ import {
MdCancel,
MdInventory2,
MdHourglassEmpty,
MdRoute,
MdSkipNext,
MdTune,
MdMyLocation,
MdOutlineLocalShipping,
@@ -161,30 +159,34 @@ const pillFieldSx = () => ({
'& .MuiAutocomplete-endAdornment .MuiSvgIcon-root': { color: DT.textMuted }
});
// Status palette — drives tab pills, row status badges, dialogs.
// Status palette — drives tab pills, row status badges, dialogs. Keys are
// Doormile's real booking statuses, lowercased (row.status is compared
// lowercased everywhere below so exact backend casing doesn't matter).
const STATUS_META = {
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
accepted: { label: 'Accepted', color: '#6366f1', icon: MdPersonPin },
arrived: { label: 'Arrived', color: '#06b6d4', icon: MdLocationOn },
picked: { label: 'Picked', color: '#8b5cf6', icon: MdInventory2 },
active: { label: 'Active', color: '#14b8a6', icon: MdRoute },
skipped: { label: 'Skipped', color: '#f97316', icon: MdSkipNext },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
all: { label: 'All', color: '#94a3b8', icon: MdAllInclusive },
pending_pickup: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
miler_assigned: { label: 'Assigned', color: '#6366f1', icon: MdPersonPin },
pickup_scheduled: { label: 'Scheduled', color: '#06b6d4', icon: MdHistoryToggleOff },
at_customer: { label: 'At Customer', color: '#8b5cf6', icon: MdLocationOn },
picked_up: { label: 'Picked Up', color: '#14b8a6', icon: MdInventory2 },
at_hub: { label: 'At Hub', color: '#0ea5e9', icon: MdStorefront },
delivered: { label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
cancelled: { label: 'Cancelled', color: '#ef4444', icon: MdCancel }
};
// Ordered status list driving the tabs row (left → right). Each entry binds
// the visual meta above to the `currentStatus` key the queries use AND to the
// `batchCounts` key (so the chip count for the tab is one lookup).
// Ordered status list driving the tabs row (left → right). `status` is the
// lowercased key into STATUS_META/batchCounts; `apiStatus` is the exact
// casing Doormile's /admin/bookings status filter expects.
const STATUS_TABS = [
{ status: 'pending', countKey: 'uncoveredLength' },
{ status: 'accepted', countKey: 'assignedLength' },
{ status: 'arrived', countKey: 'arrivedLength' },
{ status: 'picked', countKey: 'pickedLength' },
{ status: 'active', countKey: 'activeLength' },
{ status: 'skipped', countKey: 'skippedLength' },
{ status: 'delivered', countKey: 'coveredLength' },
{ status: 'cancelled', countKey: 'cancelLength' }
{ status: 'all', apiStatus: 'all', countKey: 'all' },
{ status: 'pending_pickup', apiStatus: 'Pending_Pickup', countKey: 'pending_pickup' },
{ status: 'miler_assigned', apiStatus: 'Miler_Assigned', countKey: 'miler_assigned' },
{ status: 'pickup_scheduled', apiStatus: 'Pickup_Scheduled', countKey: 'pickup_scheduled' },
{ status: 'at_customer', apiStatus: 'At_Customer', countKey: 'at_customer' },
{ status: 'picked_up', apiStatus: 'Picked_Up', countKey: 'picked_up' },
{ status: 'at_hub', apiStatus: 'At_Hub', countKey: 'at_hub' },
{ status: 'delivered', apiStatus: 'Delivered', countKey: 'delivered' },
{ status: 'cancelled', apiStatus: 'Cancelled', countKey: 'cancelled' }
];
// KPI palette + icons — mirrors the four cards across the top of the page.
@@ -321,7 +323,7 @@ const Deliveries = () => {
const [appId, setAppId] = useState(0);
const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD'));
const [tabstatus, setTabstatus] = useState('Pending');
const [tabstatus, setTabstatus] = useState('All');
const [tabvalue, setTabvalue] = useState(0);
const [open, setOpen] = useState(false);
const [kms, setKms] = useState('');
@@ -331,7 +333,7 @@ const Deliveries = () => {
const [currentorder, setCurrentorder] = useState({});
const [deliverylat, setDeliverylat] = useState('');
const [deliverylong, setDeliverylong] = useState('');
const [currentStatus, setCurrentStatus] = useState('pending');
const [currentStatus, setCurrentStatus] = useState('all');
const [updateStatus, setUpdateStatus] = useState('delivered');
const locationRef = useRef(null);
const tenantRef = useRef(null);
@@ -516,40 +518,9 @@ const Deliveries = () => {
setPage(0);
setTabvalue(i);
setRowsPerPage(50);
if (i === 0) {
setTabstatus('Pending');
setCurrentStatus('pending');
}
if (i === 1) {
setTabstatus('Assigned');
setCurrentStatus('accepted');
}
if (i === 2) {
setTabstatus('Arrived');
setCurrentStatus('arrived');
}
if (i === 3) {
setTabstatus('Picked');
setCurrentStatus('picked');
}
if (i === 4) {
setTabstatus('Active');
setCurrentStatus('active');
}
if (i === 5) {
setTabstatus('Skipped');
setCurrentStatus('skipped');
}
if (i === 6) {
setTabstatus('Delivered');
setCurrentStatus('delivered');
}
if (i === 7) {
setTabstatus('Cancelled');
setCurrentStatus('cancelled');
}
console.log(i);
const tab = STATUS_TABS[i];
setTabstatus(STATUS_META[tab.status]?.label || tab.status);
setCurrentStatus(tab.apiStatus);
setSearchword('');
};
@@ -647,21 +618,10 @@ const Deliveries = () => {
const q = String(debouncedSearch || '').trim().toLowerCase();
return countSourceRows.filter((r) => {
if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false;
const s = String(r.orderstatus || '').toLowerCase();
if (wantStatus && s !== wantStatus) return false;
const s = String(r.status || '').toLowerCase();
if (wantStatus && wantStatus !== 'all' && s !== wantStatus) return false;
if (q) {
const hay = [
r.deliverycustomer,
r.deliveryaddress,
r.deliverysuburb,
r.pickupcustomer,
r.pickupaddress,
r.pickupsuburb,
r.orderid,
r.tenantname,
r.ridername,
r.username
]
const hay = [r.bookingreference, r.bookingid, r.pickupaddress, r.deliveryaddress, r.assignedmileruserid]
.map((v) => String(v || '').toLowerCase())
.join(' ');
if (!hay.includes(q)) return false;
@@ -674,49 +634,12 @@ const Deliveries = () => {
// *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical
// (countData?.uncoveredLength → batchCounts.uncoveredLength).
const batchCounts = useMemo(() => {
const c = {
uncoveredLength: 0,
assignedLength: 0,
arrivedLength: 0,
pickedLength: 0,
activeLength: 0,
skippedLength: 0,
coveredLength: 0,
cancelLength: 0
};
const c = {};
countSourceRows.forEach((r) => {
if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return;
const s = String(r.orderstatus || '').toLowerCase();
switch (s) {
case 'pending':
c.uncoveredLength += 1;
break;
case 'accepted':
case 'assigned':
c.assignedLength += 1;
break;
case 'arrived':
c.arrivedLength += 1;
break;
case 'picked':
c.pickedLength += 1;
break;
case 'active':
c.activeLength += 1;
break;
case 'skipped':
c.skippedLength += 1;
break;
case 'delivered':
c.coveredLength += 1;
break;
case 'cancelled':
case 'canceled':
c.cancelLength += 1;
break;
default:
break;
}
const s = String(r.status || '').toLowerCase();
c[s] = (c[s] || 0) + 1;
c.all = (c.all || 0) + 1;
});
return c;
}, [countSourceRows, selectedBatch]);
@@ -1433,7 +1356,7 @@ const Deliveries = () => {
{(() => {
const showAction = tabstatus !== 'Cancelled' && tabstatus !== 'Delivered';
const showSelect = tabstatus == 'Created';
const totalCols = 15 + (showAction ? 1 : 0) + (showSelect ? 1 : 0);
const totalCols = 7 + (showAction ? 1 : 0) + (showSelect ? 1 : 0);
return isMobile ? (
/* ===================== MOBILE: card list ===================== */
<MobileCardList sx={{ p: 1.25 }}>
@@ -1461,15 +1384,14 @@ const Deliveries = () => {
</Stack>
)}
{filteredRows.map((row, index) => {
const rowStatusMeta = STATUS_META[String(row.orderstatus || '').toLowerCase()] || {
label: row.orderstatus || '—',
const rowStatusMeta = STATUS_META[String(row.status || '').toLowerCase()] || {
label: row.status || '—',
color: '#94a3b8',
icon: MdHistoryToggleOff
};
const RowStatusIcon = rowStatusMeta.icon;
const isSelected = !!deliverylist.find((res1) => res1.orderheaderid == row.orderheaderid);
const isOpen = productCollapse?.orderid === row?.orderid;
const chipSx = (c) => ({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: c, fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap' });
return (
<MobileCard
key={row.orderheaderid ?? `${row.tenantname}-${index}`}
@@ -1551,54 +1473,35 @@ const Deliveries = () => {
</Stack>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, color: DT.textPrimary, fontSize: 15 }} noWrap>
{row.tenantname}
{row.bookingreference || `#${row.bookingid}`}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{[row.tenantsuburb, row.applocation].filter(Boolean).join(' · ') || '—'}
{row.createdat ? dayjs(row.createdat).format('DD/MM/YYYY hh:mm A') : '—'}
</Typography>
</Box>
</Stack>
}
>
<MobileFieldGrid>
<MobileField label="Order / Location" full>
<MobileField label="Pickup" full>
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
{`${row.locationname}-(${row.locationsuburb})`}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{row.orderid} · {row.deliveryid}
{row.pickupaddress || '—'}
</Typography>
</MobileField>
<MobileField label="Pickup">
<MobileField label="Delivery" full>
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
{row.pickupcustomer || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{row.pickupcontactno}
{row.deliveryaddress || '—'}
</Typography>
</MobileField>
<MobileField label="Drop">
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }} noWrap>
{row.deliverycustomer || '—'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{row.deliverycontactno}
</Typography>
</MobileField>
<MobileField label="Rider" full>
{row.ridername ? (
<MobileField label="Miler" full>
{row.assignedmileruserid ? (
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color="#8b5cf6" size={24}>
<MdDirectionsBike size={13} />
</AccentAvatar>
<Stack sx={{ minWidth: 0 }}>
<Typography sx={{ fontSize: 13, fontWeight: 700, color: DT.textPrimary }} noWrap>
{row.ridername}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
ID #{row.userid} · {row.ridercontact || '—'}
</Typography>
</Stack>
<Typography sx={{ fontSize: 13, fontWeight: 700, color: DT.textPrimary }} noWrap>
#{row.assignedmileruserid}
</Typography>
</Stack>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 600 }}>
@@ -1606,40 +1509,6 @@ const Deliveries = () => {
</Typography>
)}
</MobileField>
<MobileField label="ETA" value={row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : '—'} />
<MobileField label="Transit">
<Box sx={chipSx('#06b6d4')}>{row.transitminutes || 0}m</Box>
</MobileField>
<MobileField label="Kms · plan / act">
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
<Box sx={chipSx('#ef4444')}>{row.kms || 0} km</Box>
<Box sx={chipSx('#10b981')}>{row.cumulativekms || 0} km</Box>
</Stack>
</MobileField>
<MobileField label="Amount · chg / amt">
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
<Box sx={chipSx('#ef4444')}> {row.deliverycharges?.toFixed(2) ?? '0.00'}</Box>
<Box sx={chipSx('#10b981')}> {row.deliveryamt?.toFixed(2) ?? '0.00'}</Box>
</Stack>
</MobileField>
<MobileField label="Qty" value={row.Quantity || '—'} />
<MobileField label="COD">
<Typography sx={{ fontSize: 13, fontWeight: 800, color: row.collectionamt ? '#ef4444' : DT.textMuted }}>
{row.collectionamt ? `${row.collectionamt.toFixed(2)}` : '—'}
</Typography>
</MobileField>
<MobileField label="Step">
{row.step ? (
<Box sx={{ ...chipSx('#C01227'), minWidth: 30, fontWeight: 800 }}>{row.step}</Box>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
)}
</MobileField>
{row.notes && (
<MobileField label="Notes" full>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>{row.notes}</Typography>
</MobileField>
)}
</MobileFieldGrid>
{isOpen && (
<Box sx={{ mt: 1.5, p: 1.25, borderRadius: 2, bgcolor: DT.surfaceAlt, border: `1px solid ${DT.divider}` }}>
@@ -1726,19 +1595,11 @@ const Deliveries = () => {
)}
<TableCell>#</TableCell>
<TableCell>Status</TableCell>
<TableCell>Tenant</TableCell>
<TableCell>Order / Location</TableCell>
<TableCell>Booking</TableCell>
<TableCell>Pickup</TableCell>
<TableCell>Drop</TableCell>
<TableCell>Rider</TableCell>
<TableCell>ETA</TableCell>
<TableCell>Transit</TableCell>
<TableCell>Kms</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Notes</TableCell>
<TableCell>Step</TableCell>
<TableCell>Qty</TableCell>
<TableCell>COD</TableCell>
<TableCell>Delivery</TableCell>
<TableCell>Miler</TableCell>
<TableCell>Created</TableCell>
{showAction && <TableCell align="right">Action</TableCell>}
</TableRow>
</TableHead>
@@ -1774,8 +1635,8 @@ const Deliveries = () => {
</TableRow>
)}
{filteredRows.map((row, index) => {
const rowStatusMeta = STATUS_META[String(row.orderstatus || '').toLowerCase()] || {
label: row.orderstatus || '—',
const rowStatusMeta = STATUS_META[String(row.status || '').toLowerCase()] || {
label: row.status || '—',
color: '#94a3b8',
icon: MdHistoryToggleOff
};
@@ -1856,108 +1717,38 @@ const Deliveries = () => {
</Typography>
</Stack>
</TableCell>
{/* Tenants */}
{/* Booking */}
<TableCell>
<Tooltip title={row.tenantadress}>
<Stack>
<Typography noWrap variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
{row.tenantname}
</Typography>
<Typography noWrap variant="caption" sx={{ color: DT.textSecondary }}>
{row.tenantsuburb}
</Typography>
<Typography noWrap variant="caption" sx={{ color: DT.textMuted }}>
{row.applocation}
</Typography>
</Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
{row.bookingreference || `#${row.bookingid}`}
</Typography>
</TableCell>
{/* Pickup */}
<TableCell sx={{ maxWidth: 220 }}>
<Tooltip title={row.pickupaddress || ''} placement="top">
<Typography variant="body2" noWrap>
{row.pickupaddress || '—'}
</Typography>
</Tooltip>
</TableCell>
{/* order details */}
<TableCell align="left">
<Tooltip title="Location Name-Suburb" placement="top">
<Typography variant="subtitle1" noWrap>
{`${row.locationname}-(${row.locationsuburb})`}
{/* Delivery */}
<TableCell sx={{ maxWidth: 220 }}>
<Tooltip title={row.deliveryaddress || ''} placement="top">
<Typography variant="body2" noWrap>
{row.deliveryaddress || '—'}
</Typography>
</Tooltip>
<Stack display={'flex'} flexDirection={'row'} gap={3}>
<Stack>
<Tooltip title="Order Id" placement="top">
<Typography variant="body2" noWrap>
{row.orderid}
</Typography>
</Tooltip>
<Tooltip title="Ordered date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.orderdate).utc().format('DD/MM/YYYY')}
</Typography>
<Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.orderdate).utc().format('hh:mm A')}
</Typography>
</Tooltip>
</Stack>
-
<Stack>
<Tooltip title="Delivery Id" placement="top">
<Typography variant="body2" noWrap>
{row.deliveryid}
</Typography>
</Tooltip>
<Tooltip title="Delivery date" placement="top">
<Typography noWrap sx={{ fontSize: '12px' }}>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')}
</Typography>
<Typography noWrap sx={{ fontSize: '11px' }}>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
</Typography>
</Tooltip>
</Stack>
</Stack>
</TableCell>
{/* pickup */}
<TableCell align="left">
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.pickupcustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>{row.pickupcontactno}</Typography>
<Tooltip title={row.Pickupaddress} sx={{ whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
{row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : '—')}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* drop */}
<TableCell align="left">
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.deliverycustomer}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>{row.deliverycontactno}</Typography>
<Tooltip title={row.deliveryaddress}>
<Typography variant="caption" sx={{ color: DT.textMuted, whiteSpace: 'nowrap' }}>
{row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : '—')}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* rider */}
{/* Miler */}
<TableCell>
{row.ridername ? (
{row.assignedmileruserid ? (
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color="#8b5cf6" size={28}>
<MdDirectionsBike size={14} />
</AccentAvatar>
<Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
{row.ridername}
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
ID #{row.userid} · {row.ridercontact || '—'}
</Typography>
</Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
#{row.assignedmileruserid}
</Typography>
</Stack>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted, fontWeight: 600 }}>
@@ -1965,116 +1756,10 @@ const Deliveries = () => {
</Typography>
)}
</TableCell>
{/* Estimated Delivery Time */}
<TableCell align="left">
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
{row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : '—'}
</Typography>
</TableCell>
{/* Transit Minutes */}
<TableCell align="left">
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.5,
borderRadius: 999,
bgcolor: tint('#06b6d4'),
color: '#06b6d4',
fontWeight: 700,
fontSize: 12,
border: `1px solid ${edge('#06b6d4')}`
}}
>
{row.transitminutes || 0}m
</Box>
</TableCell>
{/* kms */}
{/* Created */}
<TableCell>
<Stack direction="column" spacing={0.5} alignItems="flex-start">
<Tooltip title="Planned KMS" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#ef4444'), color: '#ef4444', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#ef4444')}`, whiteSpace: 'nowrap', minWidth: 75 }}>
{row.kms || 0} km
</Box>
</Tooltip>
<Tooltip title="Actual KMS" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#10b981'), color: '#10b981', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#10b981')}`, whiteSpace: 'nowrap', minWidth: 75 }}>
{row.cumulativekms || 0} km
</Box>
</Tooltip>
</Stack>
</TableCell>
{/* amount */}
<TableCell align="left">
<Stack direction="column" spacing={0.5} alignItems="flex-start">
<Tooltip title="Delivery Charge" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#ef4444'), color: '#ef4444', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#ef4444')}`, whiteSpace: 'nowrap', minWidth: 85 }}>
{row.deliverycharges?.toFixed(2) ?? '0.00'}
</Box>
</Tooltip>
<Tooltip title="Delivery Amount" placement="top">
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', px: 1.25, py: 0.375, borderRadius: 999, bgcolor: tint('#10b981'), color: '#10b981', fontWeight: 700, fontSize: 11, border: `1px solid ${edge('#10b981')}`, whiteSpace: 'nowrap', minWidth: 85 }}>
{row.deliveryamt?.toFixed(2) ?? '0.00'}
</Box>
</Tooltip>
</Stack>
</TableCell>
{/* notes */}
<TableCell>
{row.notes ? (
<Tooltip title={row.notes}>
<Typography variant="caption" sx={{ color: DT.textSecondary, maxWidth: 160, display: 'inline-block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{row.notes}
</Typography>
</Tooltip>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
)}
</TableCell>
{/* step */}
<TableCell align="center">
{row.step ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 30,
height: 24,
px: 0.875,
borderRadius: 999,
bgcolor: tint('#C01227'),
border: `1px solid ${edge('#C01227')}`,
color: '#C01227',
fontWeight: 800,
fontSize: 11
}}
>
{row.step}
</Box>
) : (
<Typography variant="caption" sx={{ color: DT.textMuted }}></Typography>
)}
</TableCell>
{/* qty */}
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: row.Quantity ? DT.textPrimary : DT.textMuted, whiteSpace: 'nowrap' }}>
{row.Quantity || '—'}
</Typography>
</TableCell>
{/* COD */}
<TableCell>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
color: row.collectionamt ? '#ef4444' : DT.textMuted,
whiteSpace: 'nowrap'
}}
>
{row.collectionamt ? `${row.collectionamt.toFixed(2)}` : '—'}
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{row.createdat ? dayjs(row.createdat).format('DD/MM/YYYY hh:mm A') : '—'}
</Typography>
</TableCell>
{/* Action */}
@@ -2278,7 +1963,7 @@ const Deliveries = () => {
}
}}
>
{selectedRow?.orderstatus !== 'delivered' && (
{String(selectedRow?.status || '').toLowerCase() !== 'delivered' && (
<MenuItem
onClick={() => {
notifyRiderMutation.mutate(selectedRow.userfcmtoken);
@@ -2289,7 +1974,9 @@ const Deliveries = () => {
Notify Rider
</MenuItem>
)}
{['pending', 'accepted', 'arrived'].includes(selectedRow?.orderstatus) && (
{['pending_pickup', 'miler_assigned', 'pickup_scheduled', 'at_customer'].includes(
String(selectedRow?.status || '').toLowerCase()
) && (
<MenuItem
onClick={() => {
if (!appId) {
@@ -2314,7 +2001,7 @@ const Deliveries = () => {
setDeliverylong(selectedRow.droplon);
setNotes(selectedRow.notes);
setDeliveryamount(selectedRow.deliveryamount);
setUpdateStatus(selectedRow.orderstatus || 'delivered');
setUpdateStatus(selectedRow.status || 'delivered');
setCurrentorder(selectedRow);
setDialogopen(true);
handleMenuClose();
@@ -2324,7 +2011,7 @@ const Deliveries = () => {
Update Status
</MenuItem>
)}
{selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && (
{!['cancelled', 'delivered'].includes(String(selectedRow?.status || '').toLowerCase()) && (
<MenuItem
sx={{ color: '#ef4444 !important' }}
onClick={() => {

View File

@@ -0,0 +1,485 @@
import { useState, Fragment } from 'react';
import {
Avatar,
Box,
Button,
Chip,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
IconButton,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
MdDeploymentUnit,
MdOutlineWarehouse,
MdOutlineCheckCircle,
MdOutlineBarChart,
MdOutlineLocationCity,
MdAdd,
MdEdit,
MdKeyboardArrowDown,
MdKeyboardArrowUp,
MdTwoWheeler
} from 'react-icons/md';
import StatCard from 'components/nearle_components/StatCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { OpenToast } from 'components/third-party/OpenToast';
import { fetchHubs, createHub, updateHub, fetchAllRiders } from 'pages/api/api';
// ============================================================================
// Design tokens — mirrors the DT block used across the console (see
// CLAUDE.md §6). Brand red #C01227 is the canonical primary.
// ============================================================================
const DT = {
radiusPill: 999,
radiusCard: 16,
radiusInner: 12,
shadowSoft: '0 14px 40px rgba(15, 23, 42, 0.10)',
shadowMd: '0 8px 24px rgba(15, 23, 42, 0.08)',
shadowPop: '0 18px 50px rgba(15, 23, 42, 0.18)',
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';
const CITIES = ['Coimbatore', 'Hyderabad', 'Bangalore', 'Chennai'];
const HUB_TYPES = [
{ value: 'sorting_center', label: 'Sorting Center' },
{ value: 'spoke', label: 'Spoke' },
{ value: 'pickup_point', label: 'Pickup Point' }
];
const AccentAvatar = ({ color, selected, size = 24, children }) => (
<Avatar
sx={{
width: size,
height: size,
bgcolor: selected ? color : soft(color),
color: selected ? '#fff' : color,
transition: 'background-color 0.15s, color 0.15s'
}}
>
{children}
</Avatar>
);
const EMPTY_FORM = { hubname: '', hubtype: 'sorting_center', city: CITIES[0], capacity: '', contact: '', address: '', pincode: '' };
const Hubs = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient();
const [cityFilter, setCityFilter] = useState('All');
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHub, setEditingHub] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [expandedHubId, setExpandedHubId] = useState(null);
const { data: hubs = [], isLoading: hubsLoading } = useQuery({
queryKey: ['fetchHubs'],
queryFn: fetchHubs
});
// Milers filtered per-hub for the expandable row. /admin/milers has no
// server-side hub filter (same limitation noted in api.js for fetchAllRiders),
// so this fetches the full list once and filters client-side.
const { data: allMilersRes } = useQuery({
queryKey: ['fetchAllRiders', 0, '', 0],
queryFn: fetchAllRiders
});
const allMilers = allMilersRes?.details || [];
const createMutation = useMutation({
mutationFn: createHub,
onSuccess: () => {
OpenToast('Hub created', 'success', 2000);
setDialogOpen(false);
setForm(EMPTY_FORM);
queryClient.invalidateQueries({ queryKey: ['fetchHubs'] });
},
onError: (err) => OpenToast(err.response?.data?.message || err.message, 'error', 3000)
});
const updateMutation = useMutation({
mutationFn: ({ id, body }) => updateHub(id, body),
onSuccess: () => {
OpenToast('Hub updated', 'success', 2000);
setDialogOpen(false);
setEditingHub(null);
setForm(EMPTY_FORM);
queryClient.invalidateQueries({ queryKey: ['fetchHubs'] });
},
onError: (err) => OpenToast(err.response?.data?.message || err.message, 'error', 3000)
});
const filteredHubs = cityFilter === 'All' ? hubs : hubs.filter((h) => h.city === cityFilter);
const kpis = [
{ key: 'total', label: 'Total Hubs', color: BRAND, icon: MdOutlineWarehouse, value: hubs.length },
{ key: 'active', label: 'Active', color: '#10b981', icon: MdOutlineCheckCircle, value: hubs.filter((h) => (h.status || 'active').toLowerCase() === 'active').length },
{
key: 'capacity',
label: 'Capacity Used',
color: '#f59e0b',
icon: MdOutlineBarChart,
value: hubs.length
? `${Math.round((hubs.reduce((s, h) => s + (h.usedcapacity || 0), 0) / (hubs.reduce((s, h) => s + (h.capacity || 0), 0) || 1)) * 100)}%`
: '0%'
},
{ key: 'cities', label: 'Cities', color: '#0ea5e9', icon: MdOutlineLocationCity, value: new Set(hubs.map((h) => h.city).filter(Boolean)).size }
];
const openCreate = () => {
setEditingHub(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (hub) => {
setEditingHub(hub);
setForm({
hubname: hub.hubname || '',
hubtype: hub.hubtype || 'sorting_center',
city: hub.city || CITIES[0],
capacity: hub.capacity || '',
contact: hub.contact || '',
address: hub.address || '',
pincode: hub.pincode || ''
});
setDialogOpen(true);
};
const handleSubmit = () => {
if (!form.hubname) {
OpenToast('Enter a hub name', 'warning', 2000);
return;
}
if (editingHub) {
updateMutation.mutate({ id: editingHub.hubid, body: form });
} else {
createMutation.mutate(form);
}
};
return (
<>
{/* ============================================= || Header || ============================================= */}
<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: { xs: 1.5, md: 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' }}>
<MdDeploymentUnit size={24} />
</Avatar>
<Box>
<Typography variant="h3">Hubs</Typography>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ mt: 0.25 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#10b981', animation: 'pulse 1.6s infinite' }} />
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
Live · {cityFilter}
</Typography>
</Stack>
</Box>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap">
{['All', ...CITIES].map((c) => (
<Chip
key={c}
label={c}
onClick={() => setCityFilter(c)}
sx={{
fontWeight: 700,
bgcolor: cityFilter === c ? BRAND : '#fff',
color: cityFilter === c ? '#fff' : DT.textSecondary,
border: `1px solid ${cityFilter === c ? BRAND : DT.borderSubtle}`,
'&:hover': { bgcolor: cityFilter === c ? BRAND : DT.surfaceAlt }
}}
/>
))}
</Stack>
</Stack>
</Paper>
{/* ============================================= || KPI Cards || ============================================= */}
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
{kpis.map((item) => {
const Icon = item.icon;
return (
<Grid item key={item.key} xs={6} sm={3}>
<StatCard title={item.label} value={item.value} icon={<Icon size={20} />} color={item.color} loading={hubsLoading} />
</Grid>
);
})}
</Grid>
{/* ============================================= || New Hub || ============================================= */}
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 1.5 }}>
<Button
variant="contained"
startIcon={<MdAdd size={16} />}
onClick={openCreate}
sx={{ borderRadius: DT.radiusPill, textTransform: 'none', fontWeight: 700, bgcolor: BRAND, boxShadow: 'none', '&:hover': { bgcolor: '#900E1D', boxShadow: 'none' } }}
>
New Hub
</Button>
</Stack>
{/* ============================================= || Table || ============================================= */}
<Paper elevation={0} sx={{ borderRadius: `${DT.radiusCard}px`, border: '1px solid', borderColor: DT.borderSubtle, overflow: 'hidden', background: '#fff' }}>
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' } }}>
<Table stickyHeader sx={{ minWidth: 900 }}>
<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}`,
py: 1.25,
px: 2
}
}}
>
<TableCell>Hub Name</TableCell>
<TableCell>City</TableCell>
<TableCell>Type</TableCell>
<TableCell>Capacity</TableCell>
<TableCell>Status</TableCell>
<TableCell align="center">Milers Assigned</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{hubsLoading && <OrdersTableSkeleton col={5} />}
{!hubsLoading && filteredHubs.length === 0 && (
<TableRow>
<TableCell colSpan={7} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdOutlineWarehouse size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No hubs to show
</Typography>
</Stack>
</TableCell>
</TableRow>
)}
{filteredHubs.map((hub) => {
const hubMilers = allMilers.filter((m) => m.hubid === hub.hubid);
const expanded = expandedHubId === hub.hubid;
return (
<Fragment key={hub.hubid}>
<TableRow
sx={{
'& td': { borderBottom: `1px solid ${DT.divider}`, py: 1.5, px: 2 },
'&:hover': { backgroundColor: DT.surfaceAlt }
}}
>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color={BRAND} size={32}>
<MdOutlineWarehouse size={16} />
</AccentAvatar>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
{hub.hubname || '—'}
</Typography>
</Stack>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
{hub.city || '—'}
</Typography>
</TableCell>
<TableCell>
<Chip
size="small"
label={HUB_TYPES.find((t) => t.value === hub.hubtype)?.label || hub.hubtype || '—'}
sx={{ bgcolor: tint('#0ea5e9'), color: '#0ea5e9', border: `1px solid ${edge('#0ea5e9')}`, fontWeight: 700 }}
/>
</TableCell>
<TableCell>
<Typography variant="body2">{hub.capacity ?? '—'}</Typography>
</TableCell>
<TableCell>
<Chip
size="small"
label={hub.status || 'Active'}
sx={{ bgcolor: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}`, fontWeight: 700 }}
/>
</TableCell>
<TableCell align="center">
<Tooltip title="View milers at this hub">
<Chip
size="small"
icon={<MdTwoWheeler size={14} />}
label={hubMilers.length}
onClick={() => setExpandedHubId(expanded ? null : hub.hubid)}
sx={{ bgcolor: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}`, fontWeight: 700, cursor: 'pointer' }}
/>
</Tooltip>
</TableCell>
<TableCell align="right">
<Stack direction="row" justifyContent="flex-end" spacing={0.75}>
<Tooltip title="Edit hub">
<IconButton
size="small"
onClick={() => openEdit(hub)}
sx={{ bgcolor: soft(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}`, '&:hover': { bgcolor: BRAND, color: '#fff' } }}
>
<MdEdit size={14} />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={() => setExpandedHubId(expanded ? null : hub.hubid)}>
{expanded ? <MdKeyboardArrowUp size={16} /> : <MdKeyboardArrowDown size={16} />}
</IconButton>
</Stack>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={7} sx={{ p: 0, border: 0 }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, bgcolor: DT.surfaceAlt }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: DT.textSecondary, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Milers at {hub.hubname}
</Typography>
{hubMilers.length === 0 ? (
<Typography variant="body2" sx={{ color: DT.textMuted, mt: 1 }}>
No milers assigned to this hub.
</Typography>
) : (
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mt: 1 }}>
{hubMilers.map((m) => (
<Chip
key={m.userid}
size="small"
icon={<MdTwoWheeler size={13} />}
label={`${m.displayname || `Miler #${m.userid}`} · ${m.availabilitystatus || '—'}`}
sx={{ bgcolor: '#fff', border: `1px solid ${DT.borderSubtle}`, fontWeight: 600 }}
/>
))}
</Stack>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
</Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* ============================================= || Create / Edit dialog || ============================================= */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth fullScreen={isMobile} PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}>
<DialogTitle sx={{ background: `linear-gradient(135deg, ${BRAND} 0%, #D35968 100%)`, color: '#fff' }}>
{editingHub ? `Edit ${editingHub.hubname}` : 'New Hub'}
</DialogTitle>
<DialogContent sx={{ mt: 2 }}>
<Grid container spacing={2.5} sx={{ mt: 0.5 }}>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Hub Name</Typography>
<TextField fullWidth value={form.hubname} onChange={(e) => setForm({ ...form, hubname: e.target.value })} />
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Hub Type</Typography>
<Select fullWidth value={form.hubtype} onChange={(e) => setForm({ ...form, hubtype: e.target.value })}>
{HUB_TYPES.map((t) => (
<MenuItem key={t.value} value={t.value}>
{t.label}
</MenuItem>
))}
</Select>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>City</Typography>
<Select fullWidth value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>
{CITIES.map((c) => (
<MenuItem key={c} value={c}>
{c}
</MenuItem>
))}
</Select>
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Capacity</Typography>
<TextField fullWidth type="number" value={form.capacity} onChange={(e) => setForm({ ...form, capacity: e.target.value })} />
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Contact</Typography>
<TextField fullWidth value={form.contact} onChange={(e) => setForm({ ...form, contact: e.target.value })} />
</Grid>
<Grid item xs={12} sm={6}>
<Typography sx={{ mb: 1 }}>Pincode</Typography>
<TextField fullWidth value={form.pincode} onChange={(e) => setForm({ ...form, pincode: e.target.value })} />
</Grid>
<Grid item xs={12}>
<Typography sx={{ mb: 1 }}>Address</Typography>
<TextField fullWidth multiline minRows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ p: 2.5 }}>
<Button onClick={() => setDialogOpen(false)} color="secondary" variant="outlined">
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={createMutation.isLoading || updateMutation.isLoading}
sx={{ bgcolor: BRAND, '&:hover': { bgcolor: '#900E1D' } }}
>
{editingHub ? 'Save Changes' : 'Create Hub'}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Hubs;

View File

@@ -40,6 +40,10 @@ const EditRider = Loadable(lazy(() => import('pages/nearle/riders/editRider')));
const Dispatch = Loadable(lazy(() => import('pages/nearle/dispatch/Dispatch')));
const DispatchPreview = Loadable(lazy(() => import('pages/nearle/dispatch/Preview')));
const Hubs = Loadable(lazy(() => import('pages/nearle/hubs/Hubs')));
const BookingDetail = Loadable(lazy(() => import('pages/nearle/bookings/BookingDetail')));
const Dashboard = Loadable(lazy(() => import('pages/nearle/dashboard/Dashboard')));
// ==============================|| MAIN ROUTING ||============================== //
@@ -121,6 +125,18 @@ const MainRoutes = {
{
path: 'dispatch/preview',
element: <DispatchPreview />
},
{
path: 'hubs',
element: <Hubs />
},
{
path: 'bookings/:id',
element: <BookingDetail />
},
{
path: 'dashboard',
element: <Dashboard />
}
]
},

View File

@@ -12,5 +12,7 @@
"customers": "Customers",
"riders": "Milers",
"dispatch": "Live Operations",
"dashboard": "Dashboard",
"hubs": "Hubs",
"Doormile": "Doormile"
}