From 1a7173239647c9e0473bb4c3a48173c327b80dbc Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 8 Jul 2026 19:12:28 +0530 Subject: [PATCH] feat: Doormile console Phase 2 complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/menu-items/nearle.js | 14 + src/pages/api/api.js | 216 ++++----- src/pages/nearle/bookings/BookingDetail.js | 308 +++++++++++++ src/pages/nearle/dashboard/Dashboard.js | 317 ++++++++++++++ src/pages/nearle/deliveries/deliveries.js | 485 ++++----------------- src/pages/nearle/hubs/Hubs.js | 485 +++++++++++++++++++++ src/routes/MainRoutes.js | 16 + src/utils/locales/en.json | 2 + 8 files changed, 1323 insertions(+), 520 deletions(-) create mode 100644 src/pages/nearle/bookings/BookingDetail.js create mode 100644 src/pages/nearle/dashboard/Dashboard.js create mode 100644 src/pages/nearle/hubs/Hubs.js diff --git a/src/menu-items/nearle.js b/src/menu-items/nearle.js index 05822ca..b0554ec 100644 --- a/src/menu-items/nearle.js +++ b/src/menu-items/nearle.js @@ -58,6 +58,13 @@ const nearle = { icon: icons.FileDoneOutlined, type: 'group', children: [ + { + id: 'dashboard', + title: , + type: 'item', + url: '/nearle/dashboard', + icon: icons.DashboardOutlined + }, { id: 'dispatch', title: , @@ -107,6 +114,13 @@ const nearle = { type: 'item', url: '/nearle/riders', icon: DirectionsBikeOutlinedIcon + }, + { + id: 'hubs', + title: , + type: 'item', + url: '/nearle/hubs', + icon: icons.DeploymentUnitOutlined } ] }; diff --git a/src/pages/api/api.js b/src/pages/api/api.js index e37aa4f..679c5dd 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -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 []; } }; diff --git a/src/pages/nearle/bookings/BookingDetail.js b/src/pages/nearle/bookings/BookingDetail.js new file mode 100644 index 0000000..5537e4b --- /dev/null +++ b/src/pages/nearle/bookings/BookingDetail.js @@ -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 }) => ( + + + + + + + {label} + + {value || 'β€”'} + + +); + +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 ; + + 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 ( + <> + + + + + + + + + + + + {booking.bookingreference || `Booking #${id}`} + + {booking.createdat ? new Date(booking.createdat).toLocaleString() : 'β€”'} + + + + + + + + {isError && ( + + Could not load full booking details β€” showing whatever came back. + + )} + + {/* Status timeline */} + {!isTerminal && ( + + + {STATUS_STEPS.map((s) => ( + + + {STEP_LABELS[s]} + + + ))} + + + )} + + + + + + Customer & Pickup + + + + + + + + + + + + Delivery + + + + + + + + + + + Assigned Miler + + {booking.assignedmileruserid || miler ? ( + + + + + + + {miler?.displayname || `Miler #${booking.assignedmileruserid}`} + + + {miler?.phone && ( + + {miler.phone} + + )} + {miler?.rating != null && ( + + + + {miler.rating} + + + )} + + + + ) : ( + + No miler assigned yet. + + )} + + + + + + + Parcel Details + + {parcels.length === 0 ? ( + + No parcel details available. + + ) : ( + + {parcels.map((p, i) => ( + + {p.description || p.name || `Parcel ${i + 1}`} + + {p.weight ? `${p.weight}kg` : ''} + + + ))} + + )} + + + + {agentDecision && ( + + + + + + + + AI Assignment Reasoning + + + + {agentDecision.reasoning || agentDecision.reason || JSON.stringify(agentDecision)} + + + + )} + + + + {!isTerminal && ( + + )} + {!isTerminal && ( + + )} + + + ); +}; + +export default BookingDetail; diff --git a/src/pages/nearle/dashboard/Dashboard.js b/src/pages/nearle/dashboard/Dashboard.js new file mode 100644 index 0000000..9a1f00d --- /dev/null +++ b/src/pages/nearle/dashboard/Dashboard.js @@ -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 ( + <> + + + + {kpis.map((item) => { + const Icon = item.icon; + return ( + + } color={item.color} loading={item.loading} /> + + ); + })} + + + + + + + + + City Breakdown + + + + + + + City + Hubs + Milers + Status + + + + {cityRows.length === 0 && ( + + + No hub data yet. + + + )} + {cityRows.map((row) => ( + + {row.city} + {row.hubs} + {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 + }} + /> + + + ))} + +
+
+
+ + + + + + + Recent Bookings + + + + + + + + + Booking + Status + Created + + + + {recentLoading && ( + + + Loading… + + + )} + {!recentLoading && recentBookings.length === 0 && ( + + + No bookings yet. + + + )} + {recentBookings.map((b) => ( + navigate(`/nearle/bookings/${b.bookingid}`)} + > + {b.bookingreference || `#${b.bookingid}`} + + + + + {b.createdat ? dayjs(b.createdat).format('DD/MM/YYYY hh:mm A') : 'β€”'} + + + ))} + +
+
+
+
+ + + + + + + Agent System Status + + + + {AGENTS.map((agent) => ( + + + + + + + {agent} + + + + + + Running + + + + ))} + + + + 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' + }} + > + + + + + + Manage Hubs + + {hubs.length} hub{hubs.length === 1 ? '' : 's'} configured + + + + + + +
+ + ); +}; + +export default Dashboard; diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index e546547..245ca12 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -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 ===================== */ @@ -1461,15 +1384,14 @@ const Deliveries = () => { )} {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 ( { - {row.tenantname} + {row.bookingreference || `#${row.bookingid}`} - {[row.tenantsuburb, row.applocation].filter(Boolean).join(' Β· ') || 'β€”'} + {row.createdat ? dayjs(row.createdat).format('DD/MM/YYYY hh:mm A') : 'β€”'} } > - + - {`${row.locationname}-(${row.locationsuburb})`} - - - {row.orderid} Β· {row.deliveryid} + {row.pickupaddress || 'β€”'} - + - {row.pickupcustomer || 'β€”'} - - - {row.pickupcontactno} + {row.deliveryaddress || 'β€”'} - - - {row.deliverycustomer || 'β€”'} - - - {row.deliverycontactno} - - - - {row.ridername ? ( + + {row.assignedmileruserid ? ( - - - {row.ridername} - - - ID #{row.userid} Β· {row.ridercontact || 'β€”'} - - + + #{row.assignedmileruserid} + ) : ( @@ -1606,40 +1509,6 @@ const Deliveries = () => { )} - - - {row.transitminutes || 0}m - - - - {row.kms || 0} km - {row.cumulativekms || 0} km - - - - - β‚Ή {row.deliverycharges?.toFixed(2) ?? '0.00'} - β‚Ή {row.deliveryamt?.toFixed(2) ?? '0.00'} - - - - - - {row.collectionamt ? `β‚Ή ${row.collectionamt.toFixed(2)}` : 'β€”'} - - - - {row.step ? ( - {row.step} - ) : ( - β€” - )} - - {row.notes && ( - - {row.notes} - - )} {isOpen && ( @@ -1726,19 +1595,11 @@ const Deliveries = () => { )} # Status - Tenant - Order / Location + Booking Pickup - Drop - Rider - ETA - Transit - Kms - Amount - Notes - Step - Qty - COD + Delivery + Miler + Created {showAction && Action} @@ -1774,8 +1635,8 @@ const Deliveries = () => { )} {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 = () => { - {/* Tenants */} + {/* Booking */} - - - - {row.tenantname} - - - {row.tenantsuburb} - - - {row.applocation} - - + + {row.bookingreference || `#${row.bookingid}`} + + + {/* Pickup */} + + + + {row.pickupaddress || 'β€”'} + - {/* order details */} - - - - {`${row.locationname}-(${row.locationsuburb})`} + {/* Delivery */} + + + + {row.deliveryaddress || 'β€”'} - - - - - - {row.orderid} - - - - - {dayjs(row.orderdate).utc().format('DD/MM/YYYY')} - - - {dayjs(row.orderdate).utc().format('hh:mm A')} - - - - - - - - - {row.deliveryid} - - - - - {dayjs(row.deliverydate).utc().format('DD/MM/YYYY')} - - - - {dayjs(row.deliverydate).utc().format('hh:mm A')} - - - - - {/* pickup */} - - - - {row.pickupcustomer} - - {row.pickupcontactno} - - - {row.pickuplocation || (row.Pickupaddress ? row.Pickupaddress.slice(0, 14) + '…' : 'β€”')} - - - - - {/* drop */} - - - - {row.deliverycustomer} - - {row.deliverycontactno} - - - {row.deliverylocation || (row.deliveryaddress ? row.deliveryaddress.slice(0, 14) + '…' : 'β€”')} - - - - - {/* rider */} + {/* Miler */} - {row.ridername ? ( + {row.assignedmileruserid ? ( - - - {row.ridername} - - - ID #{row.userid} Β· {row.ridercontact || 'β€”'} - - + + #{row.assignedmileruserid} + ) : ( @@ -1965,116 +1756,10 @@ const Deliveries = () => { )} - {/* Estimated Delivery Time */} - - - {row.expecteddeliverytime ? dayjs(row.expecteddeliverytime).format('hh:mm A') : 'β€”'} - - - {/* Transit Minutes */} - - - {row.transitminutes || 0}m - - - {/* kms */} + {/* Created */} - - - - {row.kms || 0} km - - - - - {row.cumulativekms || 0} km - - - - - {/* amount */} - - - - - β‚Ή {row.deliverycharges?.toFixed(2) ?? '0.00'} - - - - - β‚Ή {row.deliveryamt?.toFixed(2) ?? '0.00'} - - - - - {/* notes */} - - {row.notes ? ( - - - {row.notes} - - - ) : ( - β€” - )} - - {/* step */} - - {row.step ? ( - - {row.step} - - ) : ( - β€” - )} - - {/* qty */} - - - {row.Quantity || 'β€”'} - - - {/* COD */} - - - {row.collectionamt ? `β‚Ή ${row.collectionamt.toFixed(2)}` : 'β€”'} + + {row.createdat ? dayjs(row.createdat).format('DD/MM/YYYY hh:mm A') : 'β€”'} {/* Action */} @@ -2278,7 +1963,7 @@ const Deliveries = () => { } }} > - {selectedRow?.orderstatus !== 'delivered' && ( + {String(selectedRow?.status || '').toLowerCase() !== 'delivered' && ( { notifyRiderMutation.mutate(selectedRow.userfcmtoken); @@ -2289,7 +1974,9 @@ const Deliveries = () => { Notify Rider )} - {['pending', 'accepted', 'arrived'].includes(selectedRow?.orderstatus) && ( + {['pending_pickup', 'miler_assigned', 'pickup_scheduled', 'at_customer'].includes( + String(selectedRow?.status || '').toLowerCase() + ) && ( { 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 )} - {selectedRow?.orderstatus !== 'cancelled' && selectedRow?.orderstatus !== 'delivered' && ( + {!['cancelled', 'delivered'].includes(String(selectedRow?.status || '').toLowerCase()) && ( { diff --git a/src/pages/nearle/hubs/Hubs.js b/src/pages/nearle/hubs/Hubs.js new file mode 100644 index 0000000..890f416 --- /dev/null +++ b/src/pages/nearle/hubs/Hubs.js @@ -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 }) => ( + + {children} + +); + +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 || ============================================= */} + + + + + + + + Hubs + + + + Live Β· {cityFilter} + + + + + + {['All', ...CITIES].map((c) => ( + 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 } + }} + /> + ))} + + + + + {/* ============================================= || KPI Cards || ============================================= */} + + {kpis.map((item) => { + const Icon = item.icon; + return ( + + } color={item.color} loading={hubsLoading} /> + + ); + })} + + + {/* ============================================= || New Hub || ============================================= */} + + + + + {/* ============================================= || Table || ============================================= */} + + + + + + Hub Name + City + Type + Capacity + Status + Milers Assigned + Actions + + + + {hubsLoading && } + {!hubsLoading && filteredHubs.length === 0 && ( + + + + + + + + No hubs to show + + + + + )} + {filteredHubs.map((hub) => { + const hubMilers = allMilers.filter((m) => m.hubid === hub.hubid); + const expanded = expandedHubId === hub.hubid; + return ( + + + + + + + + + {hub.hubname || 'β€”'} + + + + + + {hub.city || 'β€”'} + + + + t.value === hub.hubtype)?.label || hub.hubtype || 'β€”'} + sx={{ bgcolor: tint('#0ea5e9'), color: '#0ea5e9', border: `1px solid ${edge('#0ea5e9')}`, fontWeight: 700 }} + /> + + + {hub.capacity ?? 'β€”'} + + + + + + + } + label={hubMilers.length} + onClick={() => setExpandedHubId(expanded ? null : hub.hubid)} + sx={{ bgcolor: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}`, fontWeight: 700, cursor: 'pointer' }} + /> + + + + + + openEdit(hub)} + sx={{ bgcolor: soft(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}`, '&:hover': { bgcolor: BRAND, color: '#fff' } }} + > + + + + setExpandedHubId(expanded ? null : hub.hubid)}> + {expanded ? : } + + + + + + + + + + Milers at {hub.hubname} + + {hubMilers.length === 0 ? ( + + No milers assigned to this hub. + + ) : ( + + {hubMilers.map((m) => ( + } + label={`${m.displayname || `Miler #${m.userid}`} Β· ${m.availabilitystatus || 'β€”'}`} + sx={{ bgcolor: '#fff', border: `1px solid ${DT.borderSubtle}`, fontWeight: 600 }} + /> + ))} + + )} + + + + + + ); + })} + +
+
+
+ + {/* ============================================= || Create / Edit dialog || ============================================= */} + setDialogOpen(false)} maxWidth="sm" fullWidth fullScreen={isMobile} PaperProps={{ sx: { borderRadius: { xs: 0, sm: 3 } } }}> + + {editingHub ? `Edit ${editingHub.hubname}` : 'New Hub'} + + + + + Hub Name + setForm({ ...form, hubname: e.target.value })} /> + + + Hub Type + + + + City + + + + Capacity + setForm({ ...form, capacity: e.target.value })} /> + + + Contact + setForm({ ...form, contact: e.target.value })} /> + + + Pincode + setForm({ ...form, pincode: e.target.value })} /> + + + Address + setForm({ ...form, address: e.target.value })} /> + + + + + + + + + + ); +}; + +export default Hubs; diff --git a/src/routes/MainRoutes.js b/src/routes/MainRoutes.js index db59e63..18000a6 100644 --- a/src/routes/MainRoutes.js +++ b/src/routes/MainRoutes.js @@ -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: + }, + { + path: 'hubs', + element: + }, + { + path: 'bookings/:id', + element: + }, + { + path: 'dashboard', + element: } ] }, diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 017c63e..ffaeab2 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -12,5 +12,7 @@ "customers": "Customers", "riders": "Milers", "dispatch": "Live Operations", + "dashboard": "Dashboard", + "hubs": "Hubs", "Doormile": "Doormile" }