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:
2026-07-09 11:48:45 +05:30
parent 1a71732396
commit 709a06274a
9 changed files with 326 additions and 345 deletions

View File

@@ -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
}
};

View File

@@ -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({

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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` (14), 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>

View File

@@ -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: () => {