feat: console Phase 2 final - live cancel and customer endpoints
- cancelOrder/cancelDeliveryAPI: real POST /admin/bookings/:id/cancel - getallcustomers/getcustomersummary: real /admin/customers, aliasing appcustomerid -> userid for existing call sites - BookingDetail: confirmed field names from live backend, cancel button now hits the live endpoint - customers.js: table columns show Name/Phone/Email/Total Bookings/Joined using appcustomerid/name/phone/email/totalbookings/createdat Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -136,9 +136,18 @@ export const fetchOrders = async ({ pageParam = 1, queryKey }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// TEMPORARY: backend fix in progress — pageno/pagesize are currently
|
||||
// ignored server-side and it returns every matching record on every call.
|
||||
// Slice client-side so infinite scroll doesn't dump the whole dataset on
|
||||
// page 1. Safe to remove once the backend honours pagination.
|
||||
const all = response.data.data || [];
|
||||
const size = Number(rowsPerPage);
|
||||
const start = (pageParam - 1) * size;
|
||||
const rows = all.slice(start, start + size);
|
||||
|
||||
return {
|
||||
rows: response.data.data,
|
||||
nextPage: response.data.data.length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
||||
rows,
|
||||
nextPage: start + size < all.length ? pageParam + 1 : undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -290,9 +299,14 @@ export const createAutomationDeliveries = async (variables) => {
|
||||
export const notifyRider = async () => ({ success: true });
|
||||
|
||||
// ==============================|| cancelOrder (orders) ||============================== //
|
||||
|
||||
export const cancelOrder = async (bookingid) => {
|
||||
const response = await axios.patch(`${process.env.REACT_APP_URL}/admin/bookings/${bookingid}/status`, { status: 'Cancelled' });
|
||||
const response = await axios.post(`${process.env.REACT_APP_URL}/admin/bookings/${bookingid}/cancel`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ==============================|| updateBookingStatus (bookings) ||============================== //
|
||||
export const updateBookingStatus = async (bookingid, status) => {
|
||||
const response = await axios.put(`${process.env.REACT_APP_URL}/admin/bookings/${bookingid}/status`, { status });
|
||||
return response.data;
|
||||
};
|
||||
// ==============================|| cancelMultipleOrder (orders) ||============================== //
|
||||
@@ -332,9 +346,16 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// TEMPORARY: same backend pagination bug as fetchOrders — slice client-side
|
||||
// until pageno/pagesize are honoured server-side.
|
||||
const all = response.data.data || [];
|
||||
const size = Number(rowsPerPage);
|
||||
const start = (pageParam - 1) * size;
|
||||
const rows = all.slice(start, start + size);
|
||||
|
||||
return {
|
||||
rows: response.data.data,
|
||||
nextPage: response.data.data.length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
||||
rows,
|
||||
nextPage: start + size < all.length ? pageParam + 1 : undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -389,11 +410,8 @@ export const fetchCountAPI = async () => {
|
||||
};
|
||||
|
||||
// ==============================|| cancelDeliveryAPI (deliveries) ||============================== //
|
||||
|
||||
export const cancelDeliveryAPI = async (selectedRow) => {
|
||||
const response = await axios.patch(`${process.env.REACT_APP_URL}/admin/bookings/${selectedRow.bookingid}/status`, {
|
||||
status: 'Cancelled'
|
||||
});
|
||||
const response = await axios.post(`${process.env.REACT_APP_URL}/admin/bookings/${selectedRow.bookingid}/cancel`);
|
||||
return response.data;
|
||||
};
|
||||
// ==============================|| getorderdetails (deliveries) ||============================== //
|
||||
@@ -474,50 +492,43 @@ export const getpricinglist = async () => {
|
||||
export const getallpricing = getpricinglist;
|
||||
|
||||
// ==============================|| getcustomersummary (customers) ||============================== //
|
||||
// No dedicated Doormile summary endpoint has been specified for customers —
|
||||
// derives the count from the same /admin/customers list used below.
|
||||
export const getcustomersummary = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${process.env.REACT_APP_URL}/admin/customers`);
|
||||
const customers = response.data?.data || [];
|
||||
return { Total: customers.length };
|
||||
const total = response.data?.total || customers.length;
|
||||
return { Total: total };
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || err.message || 'Something went wrong';
|
||||
OpenToast(message);
|
||||
OpenToast(err.message, 'error', 2000);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ==============================|| getallcustomers (customers) ||============================== //
|
||||
// NOTE: field names below (firstname/contactno/address/suburb/customerid/...)
|
||||
// are still the old NearlExpress shape — no Doormile customer response shape
|
||||
// has been confirmed yet. Fetches from the real endpoint; the UI will just
|
||||
// show blanks for any field that doesn't exist on a Doormile customer until
|
||||
// the actual shape is confirmed and this gets a proper field-mapping pass.
|
||||
// Backend rows key the customer id as `appcustomerid` — aliased to `userid`
|
||||
// here so any call site still expecting the old field name keeps working.
|
||||
export const getallcustomers = async ({ pageParam = 1, queryKey }) => {
|
||||
const [, , debouncedSearch, rowsPerPage] = queryKey;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${process.env.REACT_APP_URL}/admin/customers`);
|
||||
let customers = response.data?.data || [];
|
||||
const response = await axios.get(`${process.env.REACT_APP_URL}/admin/customers`, {
|
||||
params: {
|
||||
keyword: debouncedSearch || undefined,
|
||||
pageno: pageParam,
|
||||
pagesize: rowsPerPage || 20
|
||||
}
|
||||
});
|
||||
const customers = (response.data?.data || []).map((c) => ({
|
||||
...c,
|
||||
userid: c.appcustomerid
|
||||
}));
|
||||
|
||||
if (debouncedSearch) {
|
||||
const kw = debouncedSearch.toLowerCase();
|
||||
customers = customers.filter(
|
||||
(c) => (c.firstname || c.name || '').toLowerCase().includes(kw) || (c.contactno || c.phone || '').includes(debouncedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
// No documented pagination on /admin/customers — return everything as a
|
||||
// single page rather than guess at pageno/pagesize semantics.
|
||||
return {
|
||||
data: pageParam === 1 ? customers : [],
|
||||
nextPage: undefined
|
||||
data: customers,
|
||||
nextPage: customers.length === Number(rowsPerPage) ? pageParam + 1 : undefined
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || err.message || 'Something went wrong';
|
||||
|
||||
OpenToast(message);
|
||||
OpenToast(err.message, 'error', 2000);
|
||||
throw err; // IMPORTANT for React Query
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,9 +75,9 @@ const BookingDetail = () => {
|
||||
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.
|
||||
// Confirmed shape (booking id 5): bookingid, bookingreference, status,
|
||||
// pickupaddress, deliveryaddress, assignedmileruserid, createdat,
|
||||
// bookingparcels (nested parcels), serviceoptions, payments.
|
||||
const booking = data?.data || data || {};
|
||||
|
||||
const reassignMutation = useMutation({
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
MdMyLocation,
|
||||
MdPersonPin,
|
||||
MdPhone,
|
||||
MdLocationOn,
|
||||
MdEdit,
|
||||
MdGroups,
|
||||
MdOutlineGroups,
|
||||
@@ -527,7 +526,7 @@ export default function Customers() {
|
||||
) : (
|
||||
rows?.map((row, index) => (
|
||||
<MobileCard
|
||||
key={row.customerid || `${row.firstname}-${index}`}
|
||||
key={row.appcustomerid || `${row.name}-${index}`}
|
||||
accent="#C01227"
|
||||
header={
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||
@@ -537,10 +536,10 @@ export default function Customers() {
|
||||
</AccentAvatar>
|
||||
<Stack sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
|
||||
{row.firstname || '—'}
|
||||
{row.name || '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
ID #{row.customerid}
|
||||
ID #{row.appcustomerid}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -566,35 +565,10 @@ export default function Customers() {
|
||||
}
|
||||
>
|
||||
<MobileFieldGrid>
|
||||
<MobileField label="Contact" value={row.contactno || '—'} />
|
||||
<MobileField label="Location">
|
||||
{row.suburb ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 999,
|
||||
bgcolor: tint('#10b981'),
|
||||
border: `1px solid ${edge('#10b981')}`,
|
||||
color: '#10b981',
|
||||
fontSize: 11,
|
||||
fontWeight: 800
|
||||
}}
|
||||
>
|
||||
<MdLocationOn size={12} /> {row.suburb}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textMuted }}>—</Typography>
|
||||
)}
|
||||
</MobileField>
|
||||
<MobileField label="Address" full>
|
||||
<Typography sx={{ fontSize: 13, fontWeight: 600, color: DT.textPrimary }}>
|
||||
{row.address || '—'}
|
||||
</Typography>
|
||||
</MobileField>
|
||||
<MobileField label="Phone" value={row.phone || '—'} />
|
||||
<MobileField label="Email" value={row.email || '—'} />
|
||||
<MobileField label="Total Bookings" value={row.totalbookings ?? 0} />
|
||||
<MobileField label="Joined" value={row.createdat ? new Date(row.createdat).toLocaleDateString() : '—'} />
|
||||
</MobileFieldGrid>
|
||||
</MobileCard>
|
||||
))
|
||||
@@ -645,10 +619,11 @@ export default function Customers() {
|
||||
}}
|
||||
>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Contact</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Phone</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Total Bookings</TableCell>
|
||||
<TableCell>Joined</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -657,7 +632,7 @@ export default function Customers() {
|
||||
{customersIsLoading && <OrdersTableSkeleton />}
|
||||
{rows?.length === 0 && !customersIsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ py: 6 }}>
|
||||
<TableCell colSpan={7} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdGroups size={28} />
|
||||
@@ -674,7 +649,7 @@ export default function Customers() {
|
||||
) : (
|
||||
rows?.map((row, index) => (
|
||||
<TableRow
|
||||
key={row.customerid || `${row.firstname}-${index}`}
|
||||
key={row.appcustomerid || `${row.name}-${index}`}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
@@ -701,70 +676,39 @@ export default function Customers() {
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{row.firstname || '—'}
|
||||
{row.name || '—'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
ID #{row.customerid}
|
||||
ID #{row.appcustomerid}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<MdPhone size={12} color={DT.textMuted} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{row.contactno || '—'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{row.email && (
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
{row.email}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 280 }}>
|
||||
<Tooltip title={row.address || ''} placement="top">
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<MdPhone size={12} color={DT.textMuted} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: DT.textSecondary,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{row.address || '—'}
|
||||
{row.phone || '—'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.suburb ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 999,
|
||||
bgcolor: tint('#10b981'),
|
||||
border: `1px solid ${edge('#10b981')}`,
|
||||
color: '#10b981',
|
||||
fontSize: 11,
|
||||
fontWeight: 800
|
||||
}}
|
||||
>
|
||||
<MdLocationOn size={12} /> {row.suburb}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: DT.textMuted }}>—</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
{row.email || '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
||||
{row.totalbookings ?? 0}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
||||
{row.createdat ? new Date(row.createdat).toLocaleDateString() : '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit customer" placement="top">
|
||||
@@ -790,7 +734,7 @@ export default function Customers() {
|
||||
)}
|
||||
{rows?.length !== 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ borderBottom: 'none' }}>
|
||||
<TableCell colSpan={7} sx={{ borderBottom: 'none' }}>
|
||||
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
|
||||
{isFetchingNextPage || hasNextPage ? (
|
||||
<LoaderWithImage />
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
MdOutlineCheckCircle,
|
||||
MdTwoWheeler,
|
||||
MdOutlineSmartToy,
|
||||
MdDeploymentUnit,
|
||||
MdLocationCity,
|
||||
MdArrowForward,
|
||||
MdCircle
|
||||
} from 'react-icons/md';
|
||||
@@ -135,7 +135,7 @@ const Dashboard = () => {
|
||||
<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} />
|
||||
<MdLocationCity size={18} color={BRAND} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
City Breakdown
|
||||
</Typography>
|
||||
@@ -297,7 +297,7 @@ const Dashboard = () => {
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ bgcolor: BRAND, color: '#fff' }}>
|
||||
<MdDeploymentUnit size={18} />
|
||||
<MdLocationCity size={18} />
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800, color: DT.textPrimary }}>Manage Hubs</Typography>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, Fragment } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -23,31 +24,17 @@ import {
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery
|
||||
Typography
|
||||
} 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 { DeploymentUnitOutlined } from '@ant-design/icons';
|
||||
import { MdAdd, MdEdit, MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineWarehouse, MdTwoWheeler, MdStar } from 'react-icons/md';
|
||||
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { fetchHubs, createHub, updateHub, fetchAllRiders } from 'pages/api/api';
|
||||
import { fetchHubs, createHub, updateHub } 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.
|
||||
// DT token block — CLAUDE.md §6, copied verbatim.
|
||||
// ============================================================================
|
||||
const DT = {
|
||||
radiusPill: 999,
|
||||
@@ -55,7 +42,6 @@ const DT = {
|
||||
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',
|
||||
@@ -72,7 +58,18 @@ const edge = (c) => a(c, '55');
|
||||
|
||||
const BRAND = '#C01227';
|
||||
|
||||
const CITIES = ['Coimbatore', 'Hyderabad', 'Bangalore', 'Chennai'];
|
||||
// Confirmed from the backend test: hubs carry `applocationid` (1–4), not a
|
||||
// literal city string. Everything else here (contact/address/pincode as
|
||||
// editable fields) is inferred from the create-body shape, not confirmed
|
||||
// present on the GET response — flagged in the summary, not guessed silently.
|
||||
const CITY_MAP = {
|
||||
1: { name: 'Coimbatore', code: 'CBE', color: '#f59e0b' },
|
||||
2: { name: 'Hyderabad', code: 'HYD', color: '#06b6d4' },
|
||||
3: { name: 'Bangalore', code: 'BLR', color: '#8b5cf6' },
|
||||
4: { name: 'Chennai', code: 'CHN', color: '#10b981' }
|
||||
};
|
||||
const CITY_NAME_TO_ID = Object.fromEntries(Object.entries(CITY_MAP).map(([id, c]) => [c.name, Number(id)]));
|
||||
|
||||
const HUB_TYPES = [
|
||||
{ value: 'sorting_center', label: 'Sorting Center' },
|
||||
{ value: 'spoke', label: 'Spoke' },
|
||||
@@ -93,13 +90,14 @@ const AccentAvatar = ({ color, selected, size = 24, children }) => (
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
const EMPTY_FORM = { hubname: '', hubtype: 'sorting_center', city: CITIES[0], capacity: '', contact: '', address: '', pincode: '' };
|
||||
const EMPTY_FORM = { hubname: '', hubtype: 'sorting_center', city: 'Coimbatore', capacity: '', contact: '', address: '', pincode: '' };
|
||||
|
||||
const fetchAllMilers = () =>
|
||||
axios.get(`${process.env.REACT_APP_URL}/admin/milers`).then((r) => r.data?.data || []);
|
||||
|
||||
const Hubs = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [cityFilter, setCityFilter] = useState('All');
|
||||
const [selectedCity, setSelectedCity] = useState('All');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingHub, setEditingHub] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
@@ -110,14 +108,10 @@ const Hubs = () => {
|
||||
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 { data: milers = [] } = useQuery({
|
||||
queryKey: ['fetchAllMilers'],
|
||||
queryFn: fetchAllMilers
|
||||
});
|
||||
const allMilers = allMilersRes?.details || [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createHub,
|
||||
@@ -142,21 +136,14 @@ const Hubs = () => {
|
||||
onError: (err) => OpenToast(err.response?.data?.message || err.message, 'error', 3000)
|
||||
});
|
||||
|
||||
const filteredHubs = cityFilter === 'All' ? hubs : hubs.filter((h) => h.city === cityFilter);
|
||||
const filteredHubs =
|
||||
selectedCity === 'All' ? hubs : hubs.filter((h) => CITY_MAP[h.applocationid]?.name === selectedCity);
|
||||
|
||||
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 }
|
||||
{ key: 'total', label: 'Total Hubs', color: BRAND, value: hubs.length },
|
||||
{ key: 'sorting', label: 'Sorting Centers', color: '#3b82f6', value: hubs.filter((h) => h.hubtype === 'sorting_center').length },
|
||||
{ key: 'spokes', label: 'Spokes', color: '#14b8a6', value: hubs.filter((h) => h.hubtype === 'spoke').length },
|
||||
{ key: 'cities', label: 'Cities Covered', color: '#10b981', value: new Set(hubs.map((h) => h.applocationid).filter(Boolean)).size }
|
||||
];
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -169,8 +156,8 @@ const Hubs = () => {
|
||||
setForm({
|
||||
hubname: hub.hubname || '',
|
||||
hubtype: hub.hubtype || 'sorting_center',
|
||||
city: hub.city || CITIES[0],
|
||||
capacity: hub.capacity || '',
|
||||
city: CITY_MAP[hub.applocationid]?.name || 'Coimbatore',
|
||||
capacity: hub.capacity ?? '',
|
||||
contact: hub.contact || '',
|
||||
address: hub.address || '',
|
||||
pincode: hub.pincode || ''
|
||||
@@ -183,10 +170,19 @@ const Hubs = () => {
|
||||
OpenToast('Enter a hub name', 'warning', 2000);
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
hubname: form.hubname,
|
||||
hubtype: form.hubtype,
|
||||
applocationid: CITY_NAME_TO_ID[form.city],
|
||||
capacity: form.capacity === '' ? undefined : Number(form.capacity),
|
||||
contact: form.contact,
|
||||
address: form.address,
|
||||
pincode: form.pincode
|
||||
};
|
||||
if (editingHub) {
|
||||
updateMutation.mutate({ id: editingHub.hubid, body: form });
|
||||
updateMutation.mutate({ id: editingHub.hubid, body });
|
||||
} else {
|
||||
createMutation.mutate(form);
|
||||
createMutation.mutate(body);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,7 +194,7 @@ const Hubs = () => {
|
||||
sx={{
|
||||
p: { xs: 2, md: 3 },
|
||||
borderRadius: `${DT.radiusCard}px`,
|
||||
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint('#D35968')} 100%)`,
|
||||
background: 'linear-gradient(135deg, #C012270A 0%, #D359680A 100%)',
|
||||
border: '1px solid',
|
||||
borderColor: DT.borderSubtle,
|
||||
mb: { xs: 1.5, md: 2 }
|
||||
@@ -207,30 +203,30 @@ const Hubs = () => {
|
||||
<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} />
|
||||
<DeploymentUnitOutlined style={{ fontSize: 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}
|
||||
Live · {selectedCity}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{['All', ...CITIES].map((c) => (
|
||||
{['All', ...Object.values(CITY_MAP).map((c) => c.name)].map((c) => (
|
||||
<Chip
|
||||
key={c}
|
||||
label={c}
|
||||
onClick={() => setCityFilter(c)}
|
||||
onClick={() => setSelectedCity(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 }
|
||||
bgcolor: selectedCity === c ? BRAND : '#fff',
|
||||
color: selectedCity === c ? '#fff' : DT.textSecondary,
|
||||
border: `1px solid ${selectedCity === c ? BRAND : DT.borderSubtle}`,
|
||||
'&:hover': { bgcolor: selectedCity === c ? BRAND : DT.surfaceAlt }
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -240,14 +236,29 @@ const Hubs = () => {
|
||||
|
||||
{/* ============================================= || 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>
|
||||
);
|
||||
})}
|
||||
{kpis.map((item) => (
|
||||
<Grid item key={item.key} xs={6} sm={3}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: `${DT.radiusInner}px`,
|
||||
border: '1px solid',
|
||||
borderColor: DT.borderSubtle,
|
||||
borderTop: `3px solid ${item.color}`,
|
||||
background: '#fff',
|
||||
boxShadow: DT.shadowSoft
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: DT.textMuted, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Typography variant="h3" sx={{ mt: 0.5, color: DT.textPrimary }}>
|
||||
{hubsLoading ? '—' : item.value}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* ============================================= || New Hub || ============================================= */}
|
||||
@@ -264,7 +275,7 @@ const Hubs = () => {
|
||||
|
||||
{/* ============================================= || 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)' } }}>
|
||||
<TableContainer sx={{ maxHeight: 'calc(100vh - 190px)' }}>
|
||||
<Table stickyHeader sx={{ minWidth: 900 }}>
|
||||
<TableHead>
|
||||
<TableRow
|
||||
@@ -287,8 +298,8 @@ const Hubs = () => {
|
||||
<TableCell>City</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Capacity</TableCell>
|
||||
<TableCell align="center">Milers</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="center">Milers Assigned</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -309,48 +320,35 @@ const Hubs = () => {
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredHubs.map((hub) => {
|
||||
const hubMilers = allMilers.filter((m) => m.hubid === hub.hubid);
|
||||
const hubMilers = milers.filter((m) => m.hubid === hub.hubid);
|
||||
const expanded = expandedHubId === hub.hubid;
|
||||
const city = CITY_MAP[hub.applocationid];
|
||||
const isActive = (hub.status || 'active').toLowerCase() === 'active';
|
||||
return (
|
||||
<Fragment key={hub.hubid}>
|
||||
<TableRow
|
||||
sx={{
|
||||
'& td': { borderBottom: `1px solid ${DT.divider}`, py: 1.5, px: 2 },
|
||||
'&:hover': { backgroundColor: DT.surfaceAlt }
|
||||
}}
|
||||
>
|
||||
<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 variant="subtitle2" sx={{ fontWeight: 800, color: BRAND }}>
|
||||
{hub.hubname || '—'}
|
||||
</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 }}
|
||||
/>
|
||||
{city ? (
|
||||
<Chip size="small" label={city.name} sx={{ bgcolor: tint(city.color), color: city.color, border: `1px solid ${edge(city.color)}`, fontWeight: 700 }} />
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: DT.textMuted }}>
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ color: DT.textSecondary, textTransform: 'capitalize' }}>
|
||||
{(hub.hubtype || '—').replace(/_/g, ' ')}
|
||||
</Typography>
|
||||
</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
|
||||
@@ -362,6 +360,18 @@ const Hubs = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={isActive ? 'Active' : 'Inactive'}
|
||||
sx={{
|
||||
bgcolor: tint(isActive ? '#10b981' : '#ef4444'),
|
||||
color: isActive ? '#10b981' : '#ef4444',
|
||||
border: `1px solid ${edge(isActive ? '#10b981' : '#ef4444')}`,
|
||||
fontWeight: 700
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={0.75}>
|
||||
<Tooltip title="Edit hub">
|
||||
@@ -391,17 +401,55 @@ const Hubs = () => {
|
||||
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>
|
||||
<TableContainer sx={{ mt: 1, borderRadius: 2, border: `1px solid ${DT.borderSubtle}`, background: '#fff' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { color: DT.textSecondary, fontWeight: 800, fontSize: 10.5, textTransform: 'uppercase' } }}>
|
||||
<TableCell>Miler</TableCell>
|
||||
<TableCell>Phone</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Rating</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{hubMilers.map((m) => {
|
||||
const availColor = m.availabilitystatus === 'Available' ? '#10b981' : m.availabilitystatus === 'On_Break' ? '#f59e0b' : '#94a3b8';
|
||||
return (
|
||||
<TableRow key={m.userid}>
|
||||
<TableCell>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<AccentAvatar color={BRAND} size={26}>
|
||||
<MdTwoWheeler size={13} />
|
||||
</AccentAvatar>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{m.displayname || `Miler #${m.userid}`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
|
||||
{m.phone || '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
size="small"
|
||||
label={m.availabilitystatus || '—'}
|
||||
sx={{ bgcolor: tint(availColor), color: availColor, border: `1px solid ${edge(availColor)}`, fontWeight: 700 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" alignItems="center" justifyContent="flex-end" spacing={0.5}>
|
||||
<MdStar size={13} style={{ color: '#f59e0b' }} />
|
||||
<Typography variant="body2">{m.rating ?? '—'}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
@@ -416,9 +464,9 @@ const Hubs = () => {
|
||||
</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'}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}>
|
||||
<DialogTitle sx={{ background: 'linear-gradient(135deg, #C01227 0%, #D35968 100%)', color: '#fff' }}>
|
||||
{editingHub ? `Edit ${editingHub.hubname}` : 'Create New Hub'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ mt: 2 }}>
|
||||
<Grid container spacing={2.5} sx={{ mt: 0.5 }}>
|
||||
@@ -439,9 +487,9 @@ const Hubs = () => {
|
||||
<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}
|
||||
{Object.values(CITY_MAP).map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -456,7 +504,7 @@ const Hubs = () => {
|
||||
</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 })} />
|
||||
<TextField fullWidth inputProps={{ maxLength: 6 }} value={form.pincode} onChange={(e) => setForm({ ...form, pincode: e.target.value.replace(/\D/g, '') })} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography sx={{ mb: 1 }}>Address</Typography>
|
||||
|
||||
@@ -221,9 +221,6 @@ const Orders = () => {
|
||||
});
|
||||
|
||||
// ==============================|| cancelOrder ||============================== //
|
||||
// NOTE: no Doormile "cancel booking" endpoint has been specified anywhere in
|
||||
// the conversion work so far — this still calls the old /orders/updateorder
|
||||
// path and will fail against api.doormile.com until a real endpoint exists.
|
||||
const cancelOrderMutation = useMutation({
|
||||
mutationFn: cancelOrder,
|
||||
onSuccess: () => {
|
||||
|
||||
Reference in New Issue
Block a user