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:
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
|
||||
308
src/pages/nearle/bookings/BookingDetail.js
Normal file
308
src/pages/nearle/bookings/BookingDetail.js
Normal 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;
|
||||
317
src/pages/nearle/dashboard/Dashboard.js
Normal file
317
src/pages/nearle/dashboard/Dashboard.js
Normal 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;
|
||||
@@ -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 },
|
||||
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}
|
||||
#{row.assignedmileruserid}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
|
||||
ID #{row.userid} · {row.ridercontact || '—'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</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 variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
|
||||
{row.bookingreference || `#${row.bookingid}`}
|
||||
</Typography>
|
||||
<Typography noWrap variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
{row.tenantsuburb}
|
||||
</Typography>
|
||||
<Typography noWrap variant="caption" sx={{ color: DT.textMuted }}>
|
||||
{row.applocation}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
{/* order details */}
|
||||
<TableCell align="left">
|
||||
<Tooltip title="Location Name-Suburb" placement="top">
|
||||
<Typography variant="subtitle1" noWrap>
|
||||
{`${row.locationname}-(${row.locationsuburb})`}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
||||
<Stack display={'flex'} flexDirection={'row'} gap={3}>
|
||||
<Stack>
|
||||
<Tooltip title="Order Id" placement="top">
|
||||
{/* Pickup */}
|
||||
<TableCell sx={{ maxWidth: 220 }}>
|
||||
<Tooltip title={row.pickupaddress || ''} placement="top">
|
||||
<Typography variant="body2" noWrap>
|
||||
{row.orderid}
|
||||
{row.pickupaddress || '—'}
|
||||
</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">
|
||||
</TableCell>
|
||||
{/* Delivery */}
|
||||
<TableCell sx={{ maxWidth: 220 }}>
|
||||
<Tooltip title={row.deliveryaddress || ''} placement="top">
|
||||
<Typography variant="body2" noWrap>
|
||||
{row.deliveryid}
|
||||
{row.deliveryaddress || '—'}
|
||||
</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}
|
||||
#{row.assignedmileruserid}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
ID #{row.userid} · {row.ridercontact || '—'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</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={() => {
|
||||
|
||||
485
src/pages/nearle/hubs/Hubs.js
Normal file
485
src/pages/nearle/hubs/Hubs.js
Normal 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;
|
||||
@@ -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 />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -12,5 +12,7 @@
|
||||
"customers": "Customers",
|
||||
"riders": "Milers",
|
||||
"dispatch": "Live Operations",
|
||||
"dashboard": "Dashboard",
|
||||
"hubs": "Hubs",
|
||||
"Doormile": "Doormile"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user