updates on the api integration and the design changes on the whole website

This commit is contained in:
2026-08-11 11:18:40 +05:30
parent d144ff71a9
commit bf28249528
48 changed files with 2888 additions and 6361 deletions

View File

@@ -187,7 +187,6 @@ const OrdersPreview = () => {
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const location = useLocation();
console.log('location.state', location.state);
const [rider, setRider] = useState(null);
const [payment, setPayment] = useState(null);
const [finaldeliveryList, setFinalDeliveryList] = useState([]);
@@ -200,14 +199,6 @@ const OrdersPreview = () => {
const aiMode = location.state?.aiMode;
const reassignOrders = location.state?.reassignOrders;
useEffect(() => {
console.log('aiMode', aiMode);
console.log('riderToken', riderToken);
console.log('zoneData', zoneData);
console.log('metaData', metaData);
console.log('reassignOrders', reassignOrders);
}, []);
useEffect(() => {
if (!deliverylist?.length) return;
const updateDeliveryAmtList = deliverylist.map((list) => {
@@ -228,7 +219,6 @@ const OrdersPreview = () => {
});
setFinalDeliveryList(updateDeliveryAmtList);
console.log('finaldeliveryList', updateDeliveryAmtList);
}, [deliverylist]);
// ==============================|| fetchPaymentType ||============================== //
@@ -275,16 +265,27 @@ const OrdersPreview = () => {
const createNormalDeliveryMutation = useMutation({
mutationFn: finalCreatedeliveries, // for optimised delivery create
onSuccess: (data, variables) => {
console.log('data', data);
console.log('varialbles', variables);
notifyRiderMutation.mutate(rider?.userfcmtoken || riderToken); // Call notifyRider after success
onSuccess: (data) => {
// notifyRider expects a milerprofileid, not an FCM token — the server
// looks the device up itself (see dispatch/Preview.js's identical
// fix). rider?.userfcmtoken/riderToken here were always the wrong
// type, so every notify call from this page 404'd silently. Notify
// every rider actually present across the committed deliveries
// instead, keyed by the same `userid` field this page already uses
// for rider identity (see the Change Rider handlers below).
const notifiedRiderIds = new Set();
(finaldeliveryList || []).forEach((order) => {
const riderId = order.userid;
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
notifiedRiderIds.add(String(riderId));
notifyRiderMutation.mutate(riderId);
});
if (data.status == 'accepted') {
OpenToast('Delivery Created Successfully', 'success', 2000);
}
setTimeout(() => {
setIsLoading(false);
navigate('/nearle/orders');
navigate('/doormile/orders');
}, 2000);
},
onError: (error) => {
@@ -306,7 +307,7 @@ const OrdersPreview = () => {
<Stack direction="row" alignItems="center" spacing={1} sx={{ ml: 1 }}>
<Tooltip title="Back to orders" placement="top">
<IconButton
onClick={() => navigate('/nearle/orders')}
onClick={() => navigate('/doormile/orders')}
sx={{
backgroundColor: 'action.hover',
color: 'text.primary',
@@ -696,7 +697,6 @@ const OrdersPreview = () => {
return;
}
if (newValue) {
console.log('Selected:', newValue);
setPayment(newValue);
const newList = finaldeliveryList?.map((list) => ({
...list,
@@ -719,7 +719,6 @@ const OrdersPreview = () => {
}
if (newValue) {
setRider(newValue);
console.log('Selected:', newValue);
const newList = finaldeliveryList?.map((list) => ({
...list,
userid: newValue.userid,
@@ -747,7 +746,7 @@ const OrdersPreview = () => {
color="secondary"
startIcon={<ArrowBackIcon />}
onClick={() => {
navigate('/nearle/orders');
navigate('/doormile/orders');
}}
>
Back

View File

@@ -45,7 +45,14 @@ import { useNavigate } from 'react-router';
import { MdLocationCity } from 'react-icons/md';
import { TbMapPinCode } from 'react-icons/tb';
import { FaLocationDot } from 'react-icons/fa6';
import { getAdminPricing, getAdminTenant, getTenantCustomers, getTenantLocations, createExpressBooking } from 'pages/api/doormileApi';
import {
getAdminPricing,
getAdminTenant,
getAdminTenants,
getAdminCustomers,
getTenantLocations,
createExpressBooking
} from 'pages/api/doormileApi';
import { useTheme } from '@mui/material/styles';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import Loader from 'components/Loader';
@@ -175,6 +182,32 @@ const SoftPaper = (props) => (
/>
);
// Some saved locations come back from GET /admin/tenants/:id/locations with
// a blank locationname (live-confirmed, e.g. tenant 1's only location) — fall
// back to the address rather than rendering a bare icon with no text.
const locationDisplayName = (loc) => (loc?.locationname && loc.locationname.trim()) || loc?.address || 'Unnamed location';
// Tenants with many locations in the same city (DailyGrubs has 23, all in
// Coimbatore) made the old "(city)" suffix useless for telling them apart —
// sort primary-first then alphabetically so the picker is at least ordered
// the same way every time; the full address is shown per-option instead
// (see renderOption on the Business Location Autocomplete below).
const sortLocations = (locations) =>
[...(locations || [])].sort((a, b) => {
if (!!a.isprimary !== !!b.isprimary) return a.isprimary ? -1 : 1;
return locationDisplayName(a).localeCompare(locationDisplayName(b));
});
// POST /admin/expressbooking's parcels[].itemcategory is a free-text string
// (see express-console-api.md's sample body) — the old jupiter backend had a
// real /utils/getsubcategories lookup table behind this dropdown, but that
// endpoint has no equivalent on the new API (confirmed — see
// src/pages/api/CLAUDE.md's list of retired-with-no-replacement resources).
// This is a fixed, curated list rather than a live lookup so the dropdown
// actually has options; `freeSolo` lets an operator type a category that
// isn't on the list instead of being blocked.
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const Createorder1 = () => {
// ================================================= || GoogleMaps (Drawer) || =================================================
const [inputValue, setInputValue] = React.useState('');
@@ -205,6 +238,11 @@ const Createorder1 = () => {
const autocompleteService = useRef(null);
const [tenanatLocoId, setTenanatLocoId] = useState(localStorage.getItem('locationid'));
const [isLocation, setIsLocation] = useState(false);
// Drives what shows in place of the Business Location picker. A toast alone
// wasn't enough — it's easy to miss, and an empty Autocomplete with no
// toast visible reads as "broken" with zero explanation. This makes the
// reason a persistent, visible message instead.
const [locationsStatus, setLocationsStatus] = useState('loading');
const textFieldRef1 = useRef(null);
const textFieldRef2 = useRef(null);
const [collectionamt, setCollectionamt] = useState(0);
@@ -269,7 +307,6 @@ const Createorder1 = () => {
const [tenantinfo, setTenantinfo] = useState({});
const [searchword, setSearchword] = useState('');
const [clientdetailarr, setClientdetailarr] = useState([]);
const [clientdetailbusinessarr, setClientdetailbusinessarr] = useState([]);
const [admintoken, setAdmintoken] = useState();
const [tenantlocationlist, setTenantlocationlist] = useState([]);
const [tenant, setTenant] = useState({});
@@ -289,11 +326,20 @@ const Createorder1 = () => {
const [pricePerKm, setPricePerKm] = useState(0);
const [minKm, setMinKm] = useState(0);
const [totalCharge, setTotalCharge] = useState(0);
const [subCat, setSubCat] = useState([]);
const [subCatName, setSubCatName] = useState('Select ');
const [subCatId, setSubCatId] = useState();
const [category, setCategory] = useState(null);
const [weight, setWeight] = useState('');
const tid = localStorage.getItem('tenantid');
// Per express-console-api.md's "Tenant scoping" section: token tenantid
// 0/null means Doormile's own staff, who can see/act on every tenant —
// NOT an error state. A client (tenant) login carries a real tenantid and
// never needs this picker at all. loggedInTenantId is what's actually on
// the account; selectedTenantId is what the page operates as right now
// (auto-filled for a client login, chosen from the picker for staff).
const loggedInTenantId = localStorage.getItem('tenantid');
const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0';
const [selectedTenantId, setSelectedTenantId] = useState(isStaffLogin ? '' : loggedInTenantId);
const [staffTenantList, setStaffTenantList] = useState([]);
const [staffTenantListLoading, setStaffTenantListLoading] = useState(false);
const tid = selectedTenantId;
const [selectedCatChip, setSelectedCatChip] = useState(null);
const [isCustomerOpen, setIsCustomerOpen] = useState(false);
const [searchCustList, setSearchCustList] = useState('');
@@ -317,10 +363,6 @@ const Createorder1 = () => {
const [numErr2, setNumErr2] = useState(false);
const [isSms, setIsSms] = useState(0);
useEffect(() => {
console.log(isSms);
}, [isSms]);
const handleChipClick = (chipLabel) => {
setSelectedCatChip(chipLabel);
};
@@ -338,23 +380,24 @@ const Createorder1 = () => {
const fetchTenantPricing = async () => {
try {
const pricing = (await getAdminPricing()) || [];
const match = pricing.find((p) => p.tenantid === tid);
// tid comes from localStorage (always a string) but pricing.tenantid is
// numeric — a strict === here never matched, so basePrice/pricePerKm/
// minKm stayed at their 0 defaults and every order priced at ₹0.
const match = pricing.find((p) => String(p.tenantid) === String(tid));
if (match) {
setBasePrice(match.baseprice);
setPricePerKm(match.priceperkm);
setMinKm(match.basedistance);
}
} catch (error) {
console.log('fetchTenantPricing error', error);
// swallow — pricing defaults to 0 and stays there, handled upstream
}
};
useEffect(() => {
fetchTenantPricing();
}, []);
if (tid) fetchTenantPricing();
}, [tid]);
useEffect(() => {
console.log('startPoint', startPoint);
console.log('endPoint', endPoint);
if (startPoint.latitude != 0 && startPoint.longitude != 0 && endPoint.latitude != 0 && endPoint.longitude != 0) {
// getDistance();
calculateDistance(startPoint, endPoint);
@@ -363,10 +406,8 @@ const Createorder1 = () => {
const getDistance = () => {
const dist = geolib.getPreciseDistance(startPoint, endPoint, 1);
console.log(`Distance: ${dist} meters`);
const distanceInKm = (dist / 1000).toFixed(2);
const roundedDistance = Math.round(distanceInKm);
console.log('roundedDistance', roundedDistance);
setDistance(roundedDistance.toFixed(2));
if (roundedDistance < minKm) {
setTotalCharge(basePrice);
@@ -384,11 +425,9 @@ const Createorder1 = () => {
const calculateDistance = async (pickup, drop) => {
try {
const roundedDistance = await calculateDrivingDistance(pickup, drop);
console.log('calculated distance in km:', roundedDistance);
setDistance(roundedDistance);
const total = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm);
console.log('total charge:', total);
setTotalCharge(total);
setShowDistance(true);
@@ -396,11 +435,6 @@ const Createorder1 = () => {
setShowDistance(true);
setOpen4(true);
}
// ⏱️ Approximate duration
const avgSpeed = 40; // km/h (adjust if needed)
const duration = Math.round((roundedDistance / avgSpeed) * 60); // minutes
console.log(`Distance: ${roundedDistance}, Duration: ${duration}`);
} catch (error) {
console.error('Error calculating distance:', error);
}
@@ -442,7 +476,6 @@ const Createorder1 = () => {
val.contactno.toLowerCase().includes(searchword.toLowerCase())
);
});
console.log(arr);
setClientdetailarr([...arr]);
} else {
setClientdetailarr([...clientdetail]);
@@ -458,22 +491,18 @@ const Createorder1 = () => {
setTenant(info);
setTenantid(info.tenantid);
fetchAppAdminTokens();
setSubCatId(info.subcategoryid);
}
} catch (err) {
console.log(err);
// doormileAxios's response interceptor rejects with error.response.data
// (not the raw axios error), so the message lives at err.message here.
opentoast(err?.message || 'Could not load tenant info', 'error', 3000);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchtenantinfo();
}, []);
// No item-subcategory lookup endpoint in the new API.
const getsubcategories = async () => setSubCat([]);
useEffect(() => {
getsubcategories();
}, []);
if (tid) fetchtenantinfo();
}, [tid]);
// ==================================================== || fetchTiming || ====================================================
// No per-zone open/close-hours or geofence-radius endpoint in the new API
@@ -523,7 +552,6 @@ const Createorder1 = () => {
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: time ? time : 2000
});
console.log(alertmessage);
};
function closeAddressModal() {
@@ -538,29 +566,25 @@ const Createorder1 = () => {
const createsubmitobj2 = async () => {
// POST /admin/expressbooking's schema is a single flat booking object
// (tenantid, pickuplocationid, customer_phone/name, delivery* fields,
// (tenantid, pickup* fields, customer_phone/name, delivery* fields,
// service_option, finalprice, notes, parcels[]) — nothing like the old
// pickup/drop/orders triple. The drop contact becomes the booking's
// customer (new API models the delivery recipient as "the customer");
// the pickup side maps to pickuplocationid when a saved location was
// picked (tenanatLocoId/isLocation, set by the "Business Location"
// Autocomplete below — pickCust.deliverylocationid was always '' and
// never actually got wired to the picker, so this shortcut never fired),
// otherwise raw pickup* fields following the doc's own delivery*/pickup*
// naming symmetry (pickupaddress/pickuppincode/pickupcity/pickuplatitude/
// pickuplongitude — NOT shown in the doc's example, so unverified against
// the live API).
// customer (new API models the delivery recipient as "the customer").
//
// Always send raw pickupaddress/pickuppincode/pickupcity/pickuplatitude/
// pickuplongitude, even when a saved location was picked (the Business
// Location Autocomplete + gettenantlocations()'s single-location
// auto-select both copy the chosen location's fields into pickCust) —
// live-confirmed the pickuplocationid shortcut currently 500s on
// POST /admin/expressbooking (backend bug, reported; not something a
// frontend fix can work around other than avoiding the field).
const arr = {
tenantid: tenant.tenantid,
...(isLocation && tenanatLocoId
? { pickuplocationid: tenanatLocoId }
: {
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.postcode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0
}),
tenantid: Number(tid) || tenant.tenantid,
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.postcode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0,
customer_phone: dropCust.contactno || '',
customer_name: dropCust.firstname || '',
deliveryaddress: dropCust.address || '',
@@ -573,14 +597,15 @@ const Createorder1 = () => {
notes: otherinstructions || '',
parcels: [
{
itemcategory: subCat.find((c) => c.subcategoryid === subCatId)?.categoryname || 'General',
itemcategory: category || 'General',
itemdescription: 'Order',
declaredvalue: +totalCharge.toFixed(2) || 0
}
]
};
console.log('createsubmitobj2', arr);
if (!pickCust.firstname) {
if (!arr.tenantid) {
opentoast('Pick a tenant before creating an order', 'warning', 2000);
} else if (!pickCust.firstname) {
opentoast('Enter Pickup Contact Name ', 'warning', 2000);
} else if (!pickCust.contactno) {
opentoast('Enter Pickup Contact Number ', 'warning', 2000);
@@ -610,8 +635,6 @@ const Createorder1 = () => {
opentoast('Enter Drop Landmark ', 'warning', 2000);
} else if (!selectedtime) {
opentoast('Choose deliverytime ', 'warning', 2000);
} else if (!setSubCatId) {
opentoast('Choose SubCategory ', 'warning', 2000);
} else {
setLoading(true);
try {
@@ -622,82 +645,65 @@ const Createorder1 = () => {
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 1000
});
navigate('/nearle/orders');
navigate('/doormile/orders');
} else {
opentoast(createRes.message || 'Something went wrong, Cannot create order', 'warning');
}
} catch (error) {
console.log('createResErr', error);
// Previously silent — a thrown error here (network failure, CityGate
// rejection, validation error) left the operator with no feedback at
// all and a permanently stuck loading spinner (setLoading(false) was
// only reachable on the success path).
opentoast(error.response?.data?.message || error.message || 'Cannot create order', 'error', 3000);
// only reachable on the success path). doormileAxios's response
// interceptor already unwraps to error.response.data before
// rejecting, so the message lives at error.message, not
// error.response.data.message.
opentoast(error?.message || 'Cannot create order', 'error', 3000);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
console.log('shiftarr');
console.log(shiftarr);
}, [shiftarr]);
const clicked1 = (e) => {
setShift(e);
};
const dialogopen = (i, result) => {
console.log(i, result);
setOpen({ shiftsno: result.sno, sno: i.sno });
};
const dialogclose = () => {
setOpen('');
};
// ========================================================= || clientdetails || =========================================================
// GET /admin/customers (B2C directory), not /admin/tenantcustomers — that
// resource is a manually-added contact book with nothing in it unless
// someone explicitly creates an entry. This one is confirmed to auto-grow:
// POST /admin/expressbooking's customer_phone/customer_name "creates a
// Guest customer if unknown" (see express-console-api.md), so every order
// placed lands a reusable contact here — matching the old jupiter flow
// where placing an order (customerid: 0) auto-saved the customer too.
// Field shape is { appcustomerid, createdat, email, name, phone,
// totalbookings } — a single `name`, not firstname/lastname, and still no
// address at all (see the Saved Contacts dialog below).
const clientdetails = async () => {
setLoading2(true);
try {
const customers = (await getTenantCustomers()) || [];
const customers = (await getAdminCustomers()) || [];
const filtered = searchCustList
? customers.filter(
(val) =>
val.firstname?.toLowerCase().includes(searchCustList.toLowerCase()) ||
val.phone?.includes(searchCustList) ||
val.contactno?.includes(searchCustList)
)
? customers.filter((val) => val.name?.toLowerCase().includes(searchCustList.toLowerCase()) || val.phone?.includes(searchCustList))
: customers;
setClientdetail(filtered);
setCustomerlist(filtered);
setClientdetailarr(filtered.map((val) => ({ label: `${val.firstname} | ${val.phone || val.contactno}`, ...val })));
setClientdetailarr(filtered.map((val) => ({ label: `${val.name} | ${val.phone}`, ...val })));
} catch (err) {
console.log(err);
opentoast('server error', 'warning');
} finally {
setLoading2(false);
}
};
useEffect(() => {
console.log('clientdetails, by search');
clientdetails();
// clientdetailsbusiness();
}, [searchCustList.length > 3, searchCustList == '']);
// ========================================================= || clientdetailsbusiness || =========================================================
const clientdetailsbusiness = async () => {
setLoading2(true);
try {
const customers = (await getTenantCustomers()) || [];
setClientdetail(customers);
setClientdetailbusinessarr(customers.map((val) => ({ label: `${val.firstname} | ${val.phone || val.contactno}`, ...val })));
} catch (err) {
console.log(err);
opentoast('server error', 'warning');
} finally {
setLoading2(false);
}
};
// ================================================== || sendnotifications || ==================================================
// No bulk-notify endpoint in the new API — never actually called (dead in
// the original code too, only referenced from a commented-out call site).
@@ -827,20 +833,69 @@ const Createorder1 = () => {
// ============================================= || gettenantlocations (branches) || =============================================
const gettenantlocations = async () => {
if (!tid) {
// Staff login (express-console-api.md: token tenantid 0/null = Doormile
// staff, who can act on any tenant) with no tenant picked yet — the
// picker UI covers this, not an error. A genuinely tenant-less,
// non-staff account can't get here at all (isStaffLogin would be true).
setLocationsStatus('no-tenant');
return;
}
setLocationsStatus('loading');
try {
const locations = (await getTenantLocations(tid)) || [];
setTenantlocations(locations);
if (locations.length == 1) {
if (locations.length === 1) {
setLocationsStatus('ready');
setIsLocation(true);
setTenanatLocoId(locations[0].locationid);
setTenanatLocoId(locations[0].tenantlocationid);
const loc = locations[0];
setPickCust((prev) => ({
...prev,
address: loc.address || '',
suburb: loc.locationname || loc.city || '',
city: loc.city || '',
postcode: loc.pincode || '',
landmark: loc.address || '',
latitude: loc.latitude,
longitude: loc.longitude
}));
} else if (locations.length === 0) {
// No saved locations for this tenant — every pickup field below is a
// plain manual TextField anyway, so let the operator fill it in by
// hand instead of leaving the form permanently disabled.
setLocationsStatus('empty');
setIsLocation(true);
opentoast('No saved business locations — enter pickup details manually below', 'info', 3000);
} else {
setLocationsStatus('ready');
}
} catch (err) {
console.log('gettenantlocations', err);
// doormileAxios's response interceptor rejects with error.response.data
// (not the raw axios error) — so the message is err.message here, not
// err.response.data.message (that was reading through a layer that no
// longer exists at this point and always fell back to the generic text).
setTenantlocations([]);
setLocationsStatus('error');
opentoast(err?.message || 'Could not load business locations', 'error', 4000);
}
};
useEffect(() => {
gettenantlocations();
}, [tid]);
// ============================================= || fetchStaffTenantList (Doormile staff picker) || =============================================
// Only staff logins (loggedInTenantId blank/0) need this — a client login's
// tenant is already fixed by their token and this never fetches for them.
useEffect(() => {
if (!isStaffLogin) return;
setStaffTenantListLoading(true);
getAdminTenants()
.then((tenants) => setStaffTenantList((tenants || []).filter((t) => t.status === 'Active')))
.catch((err) => {
opentoast(err?.message || 'Could not load tenant list', 'error', 4000);
})
.finally(() => setStaffTenantListLoading(false));
}, []);
return (
@@ -853,7 +908,7 @@ const Createorder1 = () => {
elevation={0}
sx={{
position: 'sticky',
top: '60px',
top: 'var(--appshell-header-height, 64px)',
zIndex: 1000,
mb: 1.5,
p: { xs: 1.5, sm: 2, md: 2.5 },
@@ -911,7 +966,124 @@ const Createorder1 = () => {
</Stack>
</Stack>
{tenantLocations.length === 1 ? (
{locationsStatus === 'loading' ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.875,
borderRadius: 999,
bgcolor: '#fff',
border: '1.5px solid #e2e8f0',
color: DT.textSecondary,
fontWeight: 700,
fontSize: 13
}}
>
Loading business locations…
</Box>
) : locationsStatus === 'no-tenant' && isStaffLogin ? (
// Staff login (express-console-api.md: token tenantid 0/null
// = "Doormile's own staff... sees everything, all tenants")
// — pick which tenant to create this order for. Everything
// downstream (pricing, saved locations, the booking's own
// tenantid) is keyed off `tid`, which equals selectedTenantId.
<Autocomplete
options={staffTenantList}
loading={staffTenantListLoading}
getOptionLabel={(option) => option?.tenantname || ''}
isOptionEqualToValue={(option, value) => option?.tenantid === value?.tenantid}
PaperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 300 } }}
size="small"
onChange={(event, value) => setSelectedTenantId(value ? String(value.tenantid) : '')}
renderInput={(params) => (
<TextField
{...params}
placeholder="Pick a tenant to create this order for"
InputProps={{
...params.InputProps,
startAdornment: (
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ pl: 0.5 }}>
<Avatar sx={{ width: 22, height: 22, bgcolor: BRAND, color: '#fff' }}>
<MdPerson size={13} />
</Avatar>
</Stack>
)
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: DT.radiusPill + 'px',
bgcolor: '#fff',
fontWeight: 600,
'& fieldset': { borderColor: edge(BRAND), borderWidth: 1.5 },
'&:hover fieldset': { borderColor: BRAND },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` },
'&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 2 }
}
}}
/>
)}
/>
) : locationsStatus === 'no-tenant' ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.875,
borderRadius: 999,
bgcolor: '#fef2f2',
border: '1.5px solid #fecaca',
color: '#b91c1c',
fontWeight: 700,
fontSize: 12.5
}}
>
This login has no tenant assigned — sign in as a tenant user
</Box>
) : locationsStatus === 'error' ? (
<Box
onClick={gettenantlocations}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.875,
borderRadius: 999,
bgcolor: '#fef2f2',
border: '1.5px solid #fecaca',
color: '#b91c1c',
fontWeight: 700,
fontSize: 12.5,
cursor: 'pointer'
}}
>
Could not load business locations — tap to retry
</Box>
) : locationsStatus === 'empty' ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.875,
borderRadius: 999,
bgcolor: tint('#f59e0b'),
border: `1.5px solid ${edge('#f59e0b')}`,
color: '#b45309',
fontWeight: 700,
fontSize: 12.5
}}
>
No saved locations — enter pickup details manually below
</Box>
) : tenantLocations.length === 1 ? (
<Box
sx={{
display: 'inline-flex',
@@ -927,15 +1099,45 @@ const Createorder1 = () => {
fontSize: 13
}}
>
<MdMyLocation size={14} /> {tenantLocations[0].locationname}
<MdMyLocation size={14} /> {locationDisplayName(tenantLocations[0])}
</Box>
) : (
<Autocomplete
options={tenantLocations || []}
getOptionLabel={(option) => (option && option.locationname ? `${option.locationname} (${option.suburb || ''})` : '')}
isOptionEqualToValue={(option, value) => option?.locationid === value?.locationid}
options={sortLocations(tenantLocations)}
getOptionLabel={(option) => locationDisplayName(option)}
isOptionEqualToValue={(option, value) => option?.tenantlocationid === value?.tenantlocationid}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.tenantlocationid}>
<Stack sx={{ py: 0.25, minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography sx={{ fontWeight: 700, fontSize: 13.5, color: DT.textPrimary }} noWrap>
{locationDisplayName(option)}
</Typography>
{option.isprimary && (
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 999,
bgcolor: tint(BRAND),
color: BRAND,
fontSize: 10,
fontWeight: 800,
lineHeight: 1.4
}}
>
PRIMARY
</Box>
)}
</Stack>
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
{option.address || option.city || ''}
</Typography>
</Stack>
</Box>
)}
PaperComponent={SoftPaper}
sx={{ width: { xs: '100%', sm: 300 } }}
sx={{ width: { xs: '100%', sm: 320 } }}
size="small"
renderInput={(params) => (
<TextField
@@ -966,8 +1168,24 @@ const Createorder1 = () => {
)}
onChange={(event, value, reason) => {
if (value) {
setTenanatLocoId(value.locationid);
setTenanatLocoId(value.tenantlocationid);
setIsLocation(true);
// Also fill pickCust's raw address fields from the
// picked location — POST /admin/expressbooking's
// pickuplocationid currently 500s server-side (live-
// confirmed), so createsubmitobj2() always sends raw
// pickup* fields instead; this keeps that path working
// when a saved location is chosen here.
setPickCust((prev) => ({
...prev,
address: value.address || '',
suburb: value.locationname || value.city || '',
city: value.city || '',
postcode: value.pincode || '',
landmark: value.address || '',
latitude: value.latitude,
longitude: value.longitude
}));
}
if (reason === 'clear') {
setIsLocation(false);
@@ -1024,7 +1242,7 @@ const Createorder1 = () => {
}
}}
>
Saved Locations
Saved Contacts
</Button>
}
/>
@@ -1097,7 +1315,6 @@ const Createorder1 = () => {
<div>
<AddressAutocomplete
label="Address"
disabled={!isLocation}
id="addressAuto1"
fullWidth
value={inputValue2}
@@ -1335,7 +1552,7 @@ const Createorder1 = () => {
}
}}
>
Saved Locations
Saved Contacts
</Button>
}
/>
@@ -1418,7 +1635,6 @@ const Createorder1 = () => {
opentoast('Select Another Customer', 'error');
setSearchCustList('');
} else {
console.log('DropClient', val);
setDropCust(val);
setEndPoint({ latitude: val.latitude, longitude: val.longitude });
setAddId2(1);
@@ -1513,7 +1729,6 @@ const Createorder1 = () => {
<div>
<AddressAutocomplete
id="addressAuto2"
disabled={!isLocation}
label="Address"
fullWidth
value={inputValue3}
@@ -1744,11 +1959,8 @@ const Createorder1 = () => {
format="DD-MM-YYYY"
onChange={(e) => {
let dateres11 = dayjs().diff(dayjs(`${dayjs(e).format('YYYY-MM-DD')}`), 'd');
console.log('dateres11');
console.log(dateres11);
setSelectedtime('');
if (dateres11 <= 0) {
console.log('startdate', e);
setStartdate(e);
setEnddate(e);
@@ -1881,20 +2093,16 @@ const Createorder1 = () => {
<Typography variant="h5">Category</Typography>
<Autocomplete
disablePortal
freeSolo
id="combo-box-demo"
options={subCat}
getOptionLabel={(option) => `${option.subcategoryname}` || ''}
options={PARCEL_CATEGORIES}
value={category}
sx={{ my: 2, zIndex: '100' }}
fullWidth
renderInput={(params) => (
<TextField {...params} label={subCatName == '' ? tenant.subcategoryname : subCatName} />
)}
onChange={(event, value, reason) => {
if (value) {
console.log(value);
setSubCatName(value.subcategoryname);
setSubCatId(value.subcategoryid);
}
renderInput={(params) => <TextField {...params} label="Select" />}
onChange={(event, value) => setCategory(value || null)}
onInputChange={(event, value, reason) => {
if (reason === 'input') setCategory(value || null);
}}
/>
</Stack>
@@ -2126,10 +2334,10 @@ const Createorder1 = () => {
</Avatar>
<Stack>
<Typography variant="h5" sx={{ fontWeight: 800, color: DT.textPrimary }}>
Saved Locations
Saved Contacts
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary, fontWeight: 600 }}>
Pick a saved customer for {pickordrop === 1 ? 'Pickup' : 'Drop'}
Pick a saved contact&apos;s name &amp; phone for {pickordrop === 1 ? 'Pickup' : 'Drop'} — address isn&apos;t stored, search it below
</Typography>
</Stack>
</Stack>
@@ -2179,32 +2387,38 @@ const Createorder1 = () => {
<MdPersonPin size={26} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
No saved locations
No saved contacts
</Typography>
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
{searchCustList ? 'Try a different keyword.' : 'Save a customer from the order form to reuse it later.'}
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center', px: 2 }}>
{searchCustList ? 'Try a different keyword.' : 'Place an order with a new contact — it will show up here next time.'}
</Typography>
</Stack>
) : (
<Stack spacing={1} sx={{ minHeight: 480, maxHeight: 480, overflow: 'auto', pr: 0.5 }}>
{customerlist.map((address, index) => {
const isUsed = pickCust.customerid === address.customerid;
// GET /admin/customers (B2C directory) — { appcustomerid,
// createdat, email, name, phone, totalbookings }. No
// address/coordinates at all (that was a jupiter-only field
// on the old customer record). Picking a saved contact here
// only pre-fills the name & phone; the operator still has
// to search the address below like a brand-new contact.
const contactPhone = address.phone;
const isUsed = !!contactPhone && pickCust.phone === contactPhone;
return (
<Box
key={index}
onClick={() => {
if (isUsed) return;
setIsCustomerOpen(false);
const contact = { firstname: address.name, contactno: contactPhone, phone: contactPhone };
if (pickordrop === 1) {
setAddId1(1);
setStartPoint({ latitude: address.latitude, longitude: address.longitude });
setPickCust(address);
setPickNum(address.contactno);
setPickCust((prev) => ({ ...prev, ...contact }));
setPickNum(contactPhone);
} else {
setAddId2(1);
setEndPoint({ latitude: address.latitude, longitude: address.longitude });
setDropCust(address);
setdropNum(address.contactno);
setDropCust((prev) => ({ ...prev, ...contact }));
setdropNum(contactPhone);
}
}}
sx={{
@@ -2229,14 +2443,14 @@ const Createorder1 = () => {
}}
>
<Avatar sx={{ width: 36, height: 36, bgcolor: soft(BRAND), color: BRAND, fontWeight: 800 }}>
{String(address.firstname || '?')
{String(address.name || '?')
.charAt(0)
.toUpperCase()}
</Avatar>
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.75} flexWrap="wrap" useFlexGap>
<Typography sx={{ fontWeight: 800, color: DT.textPrimary, fontSize: 13.5 }} noWrap>
{address.firstname}
{address.name || 'Unnamed'}
</Typography>
<Box
sx={{
@@ -2253,7 +2467,7 @@ const Createorder1 = () => {
fontWeight: 800
}}
>
<FaPhoneAlt size={9} /> {address.contactno}
<FaPhoneAlt size={9} /> {contactPhone || '—'}
</Box>
{isUsed && (
<Box
@@ -2274,8 +2488,8 @@ const Createorder1 = () => {
</Box>
)}
</Stack>
<Typography variant="caption" sx={{ color: DT.textSecondary, mt: 0.25 }} noWrap>
{address.address}
<Typography variant="caption" sx={{ color: DT.textSecondary, mt: 0.25, fontStyle: address.email ? 'normal' : 'italic' }} noWrap>
{address.email || 'No address on file — search it below'}
</Typography>
</Stack>
</Box>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
/* eslint-disable no-unused-vars */
import React, { useEffect, useState, useRef } from 'react';
import { getAdminPricing, getTenantLocations, getTenantCustomers, createExpressBookingBulk } from 'pages/api/doormileApi';
import React, { useEffect, useMemo, useState, useRef } from 'react';
import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi';
import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import dayjs from 'dayjs';
@@ -74,14 +75,48 @@ dayjs.extend(utc);
const cellHeaderSx = { fontSize: 11.5, fontWeight: 700, color: '#475569', py: 0.75, px: 1 };
const cellBodySx = { fontSize: 12, py: 0.6, px: 1 };
// GET /admin/tenants/:id/locations carries no `slots` field (no per-location
// pickup-hours endpoint in the new API) — the pickup-slot Autocomplete used
// to read tenantLocation.slots, which was always undefined, leaving the
// dropdown permanently empty and the page unsubmittable. Falls back to a
// fixed 09:00-21:00 / 30-min window, same rationale as createorder1.js's
// fetchTiming().
const buildDefaultPickupSlots = () => {
const arr = [];
for (let t = dayjs().hour(9).minute(0).second(0); t.hour() < 21 || (t.hour() === 21 && t.minute() === 0); t = t.add(30, 'm')) {
arr.push({ name: t.format('hh:mm A'), time: t.format('HH:mm') });
}
return arr;
};
// Some saved locations come back with a blank locationname (live-confirmed,
// e.g. tenant 1's only location) — fall back to the address rather than
// showing an empty field/option.
const locationDisplayName = (loc) => (loc?.locationname && loc.locationname.trim()) || loc?.address || 'Unnamed location';
// Tenants with many locations in the same city (DailyGrubs has 23, all in
// Coimbatore) made a "(city)" suffix useless for telling them apart — sort
// primary-first then alphabetically; the full address is shown per-option
// instead (see renderOption on the Business Location Autocomplete below).
const sortLocations = (locations) =>
[...(locations || [])].sort((a, b) => {
if (!!a.isprimary !== !!b.isprimary) return a.isprimary ? -1 : 1;
return locationDisplayName(a).localeCompare(locationDisplayName(b));
});
const MultipleOrders = () => {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const locationRef = useRef(null);
const userid = localStorage.getItem('userid');
// tenantid is fixed for this app — comes from the logged-in session
const tenantid = localStorage.getItem('tenantid');
// Per express-console-api.md's "Tenant scoping" section: token tenantid
// 0/null means Doormile's own staff, who can act on any tenant — NOT an
// error state. A client (tenant) login carries a real tenantid and never
// needs the picker below. `tenantid` (used throughout this file already)
// now resolves to whichever the operator is currently working as.
const loggedInTenantId = localStorage.getItem('tenantid');
const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0';
// ============================== state ==============================
const [locations, setLocations] = useState([]);
@@ -92,6 +127,11 @@ const MultipleOrders = () => {
const [appId, setAppId] = useState(0);
const [locationid, setLocationid] = useState(0);
const [locationValue, setLocationValue] = useState(null);
const [locationsStatus, setLocationsStatus] = useState('loading');
const [selectedTenantId, setSelectedTenantId] = useState(isStaffLogin ? '' : loggedInTenantId);
const [staffTenantList, setStaffTenantList] = useState([]);
const [staffTenantListLoading, setStaffTenantListLoading] = useState(false);
const tenantid = selectedTenantId;
const [basePrice, setBasePrice] = useState(0);
const [pricePerKm, setPricePerKm] = useState(0);
@@ -105,7 +145,7 @@ const MultipleOrders = () => {
const [startdate, setStartdate] = useState(dayjs().format('MM-DD-YYYY'));
const [selectedtime, setSelectedtime] = useState('');
const [pickupSlotsList, setPickupSlotsList] = useState(null);
const [pickupSlotsList, setPickupSlotsList] = useState(buildDefaultPickupSlots);
const [pickupSlot, setPickupSlot] = useState(null);
const [otherinstructions, setOtherinstructions] = useState('');
@@ -148,7 +188,7 @@ const MultipleOrders = () => {
setUsers([]);
setUploadType(null);
setFileName('');
setPickupSlotsList(null);
setPickupSlotsList(buildDefaultPickupSlots());
setPickupSlot(null);
setSelectedtime('');
}, [appId]);
@@ -174,68 +214,104 @@ const MultipleOrders = () => {
const fetchTenantPricing = async (id) => {
try {
const pricing = (await getAdminPricing()) || [];
const match = pricing.find((p) => p.tenantid === id);
// id comes from localStorage (always a string) but pricing.tenantid is
// numeric — a strict === here never matched, so basePrice/pricePerKm/
// minKm stayed at their 0 defaults and every order priced at ₹0.
const match = pricing.find((p) => String(p.tenantid) === String(id));
if (match) {
setBasePrice(match.baseprice || 0);
setPricePerKm(match.priceperkm || 0);
setMinKm(match.basedistance || 0);
}
} catch (err) {
console.log('fetchTenantPricing', err);
// swallow
}
};
useEffect(() => {
if (tenantid) fetchTenantPricing(tenantid);
}, []);
}, [tenantid]);
// ============================== gettenantlocations ==============================
// Drives what shows in place of the Business Location picker. Previously
// this function was only ever called when `tenantid` was truthy, so an
// operator on a no-tenant login (the shared admin/developer account has
// tenantid: null) got a permanently empty picker with zero feedback — not
// even a toast, since the function body that would show one never ran.
const gettenantlocations = async (id) => {
if (!id) {
// Staff login with no tenant picked yet — the picker UI covers this.
setLocationsStatus('no-tenant');
return;
}
setLocationsStatus('loading');
try {
const details = (await getTenantLocations(id)) || [];
setTenantlocations(details);
if (details.length === 1) {
setTenantlocations(details);
setLocationsStatus('ready');
setPickCust(details[0]);
setLocationid(details[0].locationid);
setLocationid(details[0].tenantlocationid);
setLocationValue(details[0]);
setPickupSlotsList(details[0].slots);
} else if (details.length === 0) {
setLocationsStatus('empty');
opentoast('No saved business locations for this tenant', 'info', 3000);
} else {
setTenantlocations(details);
setLocationsStatus('ready');
}
} catch (err) {
console.log('gettenantlocations', err);
// doormileAxios's response interceptor rejects with error.response.data
// (not the raw axios error), so the message lives at err.message here.
setTenantlocations([]);
setLocationsStatus('error');
opentoast(err?.message || 'Could not load business locations', 'error', 4000);
}
};
useEffect(() => {
if (tenantid) gettenantlocations(tenantid);
gettenantlocations(tenantid);
}, [tenantid]);
// ============================== fetchStaffTenantList (Doormile staff picker) ==============================
// Only staff logins (loggedInTenantId blank/0) need this — a client login's
// tenant is already fixed by their token and this never fetches for them.
useEffect(() => {
if (!isStaffLogin) return;
setStaffTenantListLoading(true);
getAdminTenants()
.then((tenants) => setStaffTenantList((tenants || []).filter((t) => t.status === 'Active')))
.catch((err) => {
opentoast(err?.message || 'Could not load tenant list', 'error', 4000);
})
.finally(() => setStaffTenantListLoading(false));
}, []);
// ============================== clientdetails ==============================
// GET /admin/customers (B2C directory) — not /admin/tenantcustomers, which
// is an empty, manually-added contact book with no address/coordinates.
// This resource auto-grows from real orders (see the identical fix on
// createorder1.js's Saved Contacts) and has a confirmed live shape of
// { appcustomerid, createdat, email, name, phone, totalbookings } — no
// search/query param exists, so it's fetched once and filtered in-memory.
const clientdetails = async () => {
try {
const customers = (await getTenantCustomers()) || [];
const filtered = searchCustList
? customers.filter(
(val) =>
val.firstname?.toLowerCase().includes(searchCustList.toLowerCase()) ||
val.phone?.includes(searchCustList) ||
val.contactno?.includes(searchCustList)
)
: customers;
setCustomerlist(filtered);
const customers = (await getAdminCustomers()) || [];
setCustomerlist(customers);
} catch (err) {
opentoast('server error', 'warning');
}
};
useEffect(() => {
if (!tenantid) return;
const t = setTimeout(() => {
if (searchCustList === '' || searchCustList.length > 2) clientdetails();
}, 250);
return () => clearTimeout(t);
}, [searchCustList, tenantid]);
if (!tenantid || !isCustomerOpen) return;
clientdetails();
}, [tenantid, isCustomerOpen]);
const filteredCustomerList = useMemo(() => {
const q = searchCustList.trim().toLowerCase();
if (!q) return customerlist;
return customerlist.filter((c) => c.name?.toLowerCase().includes(q) || c.phone?.includes(searchCustList));
}, [customerlist, searchCustList]);
// ============================== totals ==============================
useEffect(() => {
@@ -276,27 +352,51 @@ const MultipleOrders = () => {
};
// ============================== checkbox handlers ==============================
const handleCheckboxChange = async (event, customer) => {
setLoading(true);
try {
if (event.target.checked) {
const { roundedDistance, totalcharge } = await calculateDistance(customer);
setDropCust((prev) => [...prev, { ...customer, distance: roundedDistance, totalcharge }]);
} else {
setDropCust((prev) => prev.filter((c) => c.customerid !== customer.customerid));
}
} catch (err) {
console.error('Failed to calculate distance:', err);
} finally {
setLoading(false);
// GET /admin/customers has no address/coordinates at all, so a saved
// customer can no longer be distance-calculated at selection time (that's
// what used to throw "Invalid coordinates" and silently drop the row every
// time). Add the row immediately instead — its Address cell renders an
// editable AddressAutocomplete (see the Drop List table) until the
// operator picks a real address, which is what fills in lat/lng and lets
// distance/charge be computed per-row.
const handleCheckboxChange = (event, customer) => {
const rowkey = `sel-${customer.appcustomerid}`;
if (event.target.checked) {
setDropCust((prev) => {
if (prev.some((c) => c._rowkey === rowkey)) return prev;
return [
...prev,
{
_rowkey: rowkey,
appcustomerid: customer.appcustomerid,
firstname: customer.name || '',
contactno: customer.phone || '',
address: '',
city: '',
postcode: '',
latitude: null,
longitude: null,
distance: 0,
totalcharge: 0,
quantity: 0,
collectionamt: 0
}
];
});
} else {
setDropCust((prev) => prev.filter((c) => c._rowkey !== rowkey));
}
};
// Still used by the CSV "Continue" promotion and the Drop List remove
// button. Safe to keep calling calculateDistance here — CSV rows always
// carry real latitude/longitude from the sheet, unlike Selection rows.
const handleCheckboxChange1 = async (customer) => {
const rowkey = customer._rowkey;
let wasSelected = false;
setDropCust((prev) => {
wasSelected = prev.some((c) => c.firstname === customer.firstname);
if (wasSelected) return prev.filter((c) => c.firstname !== customer.firstname);
wasSelected = prev.some((c) => c._rowkey === rowkey);
if (wasSelected) return prev.filter((c) => c._rowkey !== rowkey);
return prev;
});
if (wasSelected) return;
@@ -304,16 +404,55 @@ const MultipleOrders = () => {
try {
const { roundedDistance, totalcharge } = await calculateDistance(customer);
setDropCust((prev) => {
if (prev.some((c) => c.firstname === customer.firstname)) return prev;
if (prev.some((c) => c._rowkey === rowkey)) return prev;
return [...prev, { ...customer, distance: roundedDistance, totalcharge }];
});
} catch (err) {
console.error('Failed to calculate distance:', err);
OpenToast(`Could not calculate distance for ${customer.firstname || 'this row'}`, 'warning', 3000);
} finally {
setLoading(false);
}
};
// Parses the AddressAutocomplete `place` result (see createorder1.js's
// handleDropPlaceSelected for the same pattern) for one Drop List row,
// updates that row's address/coordinates, then recomputes its distance and
// charge now that coordinates finally exist.
const handleRowPlaceSelected = async (rowkey, place) => {
const parsed = { suburb: '', city: '', postcode: '' };
place.address_components.forEach((component) => {
component.types.forEach((type) => {
switch (type) {
case 'sublocality_level_1':
case 'sublocality':
parsed.suburb = component.long_name;
break;
case 'locality':
parsed.city = component.long_name;
break;
case 'postal_code':
parsed.postcode = component.long_name;
break;
}
});
});
const latitude = place.geometry.location.lat();
const longitude = place.geometry.location.lng();
const address = place.formatted_address || '';
setDropCust((prev) =>
prev.map((c) => (c._rowkey === rowkey ? { ...c, address, city: parsed.city, postcode: parsed.postcode, latitude, longitude } : c))
);
try {
const { roundedDistance, totalcharge } = await calculateDistance({ latitude, longitude });
setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, distance: roundedDistance, totalcharge } : c)));
} catch (err) {
console.error('Failed to calculate distance:', err);
}
};
// No zone-admin-token or bulk-notify endpoint in the new API.
const fetchAppAdminTokens = async () => setAdmintoken([]);
@@ -337,6 +476,12 @@ const MultipleOrders = () => {
receiverphone: 'contactno',
'receiveralternatephone*': 'altcontactno',
receiverfulladdress: 'address',
// Best-effort — no live sample sheet to confirm these are the tenant's
// actual column headers. Without them deliverycity/deliverypincode in
// the submit payload were always sent blank; if these names are wrong,
// behavior is unchanged from before (still blank), not worse.
receivercity: 'city',
receiverpincode: 'postcode',
receiverlatitude: 'latitude',
receiverlongitude: 'longitude',
'itemdescription*': 'description',
@@ -362,8 +507,8 @@ const MultipleOrders = () => {
const processData = (data, headers) => {
const normalizedMap = {};
for (const key in headerMap) normalizedMap[normalizeHeader(key)] = headerMap[key];
const mappedData = data.map((row) => {
const newRow = {};
const mappedData = data.map((row, idx) => {
const newRow = { _rowkey: `csv-${idx}` };
for (const key in row) {
const cleanKey = normalizeHeader(key);
const newKey = normalizedMap[cleanKey] || cleanKey;
@@ -432,11 +577,11 @@ const MultipleOrders = () => {
};
// ============================== row editors ==============================
const handleQuantityChange = (customerid, value) => {
setDropCust((prev) => prev.map((c) => (c.customerid === customerid ? { ...c, quantity: Number(value) || 0 } : c)));
const handleQuantityChange = (rowkey, value) => {
setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, quantity: Number(value) || 0 } : c)));
};
const handleCollectionAmtChange = (customerid, value) => {
setDropCust((prev) => prev.map((c) => (c.customerid === customerid ? { ...c, collectionamt: Number(value) || 0 } : c)));
const handleCollectionAmtChange = (rowkey, value) => {
setDropCust((prev) => prev.map((c) => (c._rowkey === rowkey ? { ...c, collectionamt: Number(value) || 0 } : c)));
};
// ============================== buildDeliveryTime ==============================
@@ -470,30 +615,39 @@ const MultipleOrders = () => {
opentoast('Add at least one drop customer', 'warning');
return;
}
// A Selection-sourced row (picked from Saved Customers) starts with no
// address/coordinates until the operator fills in its Address cell —
// block submit rather than send a booking with lat/lng silently at 0.
const incomplete = dropCust.find((c) => c.latitude == null || c.longitude == null);
if (incomplete) {
opentoast(`Add a delivery address for ${incomplete.firstname || 'a selected customer'} before submitting`, 'warning', 4000);
return;
}
const deliverytime = buildDeliveryTime();
// POST /admin/expressbooking/bulk takes { bookings: [...] } where each
// entry is a flat booking object (tenantid, pickuplocationid, customer_*,
// entry is a flat booking object (tenantid, pickup* fields, customer_*,
// delivery*, service_option, finalprice, notes, parcels[]) — not the old
// per-row orders/pickup/drop shape. pickup* fields follow the doc's own
// delivery*/pickup* naming symmetry (unverified — not shown in the doc's
// example, only pickuplocationid is confirmed).
// per-row orders/pickup/drop shape.
// tenantid comes from the logged-in session (see the top-level `tenantid`
// const above), not from pickCust — pickCust is a tenant *location*
// object (GET /admin/tenants/:id/locations) and was never confirmed to
// carry a tenantid back-reference, so `pickCust.tenantid` was silently
// undefined on every submit.
//
// Always send raw pickup* fields, never pickuplocationid — live-confirmed
// pickuplocationid currently 500s on POST /admin/expressbooking (and by
// extension the bulk endpoint, same handler); a saved location's own
// address/pincode/city/lat/lng (already on pickCust) work fine sent raw.
// Note: pickCust.pincode, not .postcode — that was also wrong (the real
// GET /admin/tenants/:id/locations field is `pincode`).
const bookings = dropCust.map((customer) => ({
tenantid: Number(tenantid),
...(pickCust.locationid
? { pickuplocationid: pickCust.locationid }
: {
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.postcode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0
}),
pickupaddress: pickCust.address || '',
pickuppincode: pickCust.pincode || '',
pickupcity: pickCust.city || '',
pickuplatitude: Number(pickCust.latitude) || 0,
pickuplongitude: Number(pickCust.longitude) || 0,
customer_phone: customer.contactno?.toString() || '',
customer_name: customer.firstname || '',
deliveryaddress: customer.address || '',
@@ -514,17 +668,16 @@ const MultipleOrders = () => {
const res = await createExpressBookingBulk(bookings);
if (res.success) {
opentoast('Orders Created Successfully', 'success', 2000);
navigate('/nearle/orders');
navigate('/doormile/orders');
} else {
opentoast(res?.message || 'Order creation failed. Please try again.', 'warning', 3000);
}
} catch (err) {
const msg = err.response
? err.response.data?.message || `Server error (${err.response.status})`
: err.request
? 'Network error. Check your internet connection.'
: 'Something went wrong.';
opentoast(msg, 'error', 3000);
// doormileAxios's response interceptor rejects with error.response.data
// (not the raw axios error), so the message lives at err.message here —
// err.response was always undefined at this point, so this always fell
// through to the generic "Something went wrong." fallback before.
opentoast(err?.message || 'Something went wrong.', 'error', 3000);
} finally {
setLoading(false);
setBtnLoading(false);
@@ -605,13 +758,108 @@ const MultipleOrders = () => {
</Box>
<Grid container spacing={1.75}>
<Grid item xs={12}>
{tenantLocations?.length === 1 ? (
{locationsStatus === 'loading' ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: '10px',
bgcolor: '#fff',
border: '1px solid #e2e8f0',
color: '#64748b',
fontWeight: 600,
fontSize: 13
}}
>
Loading business locations…
</Box>
) : locationsStatus === 'no-tenant' && isStaffLogin ? (
// Staff login (express-console-api.md: token tenantid
// 0/null = "Doormile's own staff... sees everything, all
// tenants") — pick which tenant this bulk order is for.
<Autocomplete
size="small"
fullWidth
options={staffTenantList}
loading={staffTenantListLoading}
getOptionLabel={(option) => option?.tenantname || ''}
isOptionEqualToValue={(option, value) => option?.tenantid === value?.tenantid}
onChange={(event, value) => setSelectedTenantId(value ? String(value.tenantid) : '')}
renderInput={(params) => (
<TextField
{...params}
label="Pick a tenant"
placeholder="Pick a tenant to create orders for"
InputLabelProps={{ shrink: true }}
sx={{
'& .MuiOutlinedInput-root': { borderRadius: '10px', height: '36px', alignItems: 'center' },
'& .MuiOutlinedInput-input': { padding: '0 14px !important' }
}}
/>
)}
/>
) : locationsStatus === 'no-tenant' ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: '10px',
bgcolor: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c',
fontWeight: 600,
fontSize: 12.5
}}
>
This login has no tenant assigned — sign in as a tenant user
</Box>
) : locationsStatus === 'error' ? (
<Box
onClick={() => gettenantlocations(tenantid)}
sx={{
display: 'flex',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: '10px',
bgcolor: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c',
fontWeight: 600,
fontSize: 12.5,
cursor: 'pointer'
}}
>
Could not load business locations — tap to retry
</Box>
) : locationsStatus === 'empty' ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: '10px',
bgcolor: '#fffbeb',
border: '1px solid #fde68a',
color: '#b45309',
fontWeight: 600,
fontSize: 12.5
}}
>
No saved business locations for this tenant
</Box>
) : tenantLocations?.length === 1 ? (
<TextField
size="small"
variant="outlined"
fullWidth
label="Business Location"
value={tenantLocations[0]?.locationname || ''}
value={tenantLocations[0] ? locationDisplayName(tenantLocations[0]) : ''}
InputProps={{
readOnly: true,
style: { color: theme.palette.primary.main },
@@ -630,22 +878,48 @@ const MultipleOrders = () => {
<Autocomplete
size="small"
fullWidth
options={tenantLocations || []}
getOptionLabel={(option) =>
option ? `${option.locationname} (${option.suburb || option.locationsuburb || ''})` : ''
}
options={sortLocations(tenantLocations)}
getOptionLabel={(option) => (option ? locationDisplayName(option) : '')}
renderOption={(props, option) => (
<Box component="li" {...props} key={option.tenantlocationid}>
<Stack sx={{ py: 0.25, minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography sx={{ fontWeight: 700, fontSize: 13.5 }} noWrap>
{locationDisplayName(option)}
</Typography>
{option.isprimary && (
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 999,
bgcolor: 'rgba(192,18,39,0.08)',
color: '#C01227',
fontSize: 10,
fontWeight: 800,
lineHeight: 1.4
}}
>
PRIMARY
</Box>
)}
</Stack>
<Typography variant="caption" sx={{ color: '#64748b' }} noWrap>
{option.address || option.city || ''}
</Typography>
</Stack>
</Box>
)}
value={locationValue}
onChange={(event, value, reason) => {
if (reason === 'clear' || !value) {
setLocationid(0);
setLocationValue(null);
setPickCust(null);
setPickupSlotsList(null);
} else {
setLocationid(value.locationid || 0);
setLocationid(value.tenantlocationid || 0);
setLocationValue(value);
setPickCust(value);
setPickupSlotsList(value?.slots || null);
}
}}
renderInput={(params) => (
@@ -746,7 +1020,7 @@ const MultipleOrders = () => {
const finalDateTime = dayjs(`${startdate} ${formattedTime}`, 'MM-DD-YYYY hh:mm A').format('YYYY-MM-DD hh:mm A');
setPickupSlot(finalDateTime);
}}
getOptionLabel={(option) => (option ? `${option.name} (${dayjs(option.time, 'HH:mm').format('hh:mm A')})` : '')}
getOptionLabel={(option) => (option ? option.name : '')}
renderInput={(params) => (
<TextField
{...params}
@@ -1183,26 +1457,40 @@ const MultipleOrders = () => {
</TableHead>
<TableBody>
{dropCust.map((customer, index) => (
<TableRow key={customer.customerid || customer.firstname || index} hover>
<TableRow key={customer._rowkey || index} hover>
<TableCell sx={cellBodySx}>{index + 1}</TableCell>
<TableCell sx={{ ...cellBodySx, whiteSpace: 'nowrap', color: '#1e293b', fontWeight: 600 }}>
{customer.firstname}
</TableCell>
<TableCell sx={{ ...cellBodySx, color: '#64748b', maxWidth: 220 }}>
<Tooltip title={customer.address || ''}>
<span
style={{
display: 'inline-block',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
verticalAlign: 'middle'
}}
>
{customer.address}
</span>
</Tooltip>
<TableCell sx={{ ...cellBodySx, color: '#64748b', maxWidth: 260 }}>
{customer.latitude == null || customer.longitude == null ? (
<AddressAutocomplete
id={`drop-address-${customer._rowkey}`}
placeholder="Search address…"
value={customer.address || ''}
onChange={(text) =>
setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c)))
}
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)}
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined}
textFieldSx={{ '& .MuiOutlinedInput-root': { height: 30, fontSize: 12, borderRadius: '8px' } }}
/>
) : (
<Tooltip title={customer.address || ''}>
<span
style={{
display: 'inline-block',
maxWidth: 260,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
verticalAlign: 'middle'
}}
>
{customer.address}
</span>
</Tooltip>
)}
</TableCell>
<TableCell align="center" sx={cellBodySx}>
{uploadType === 0 ? (
@@ -1212,7 +1500,7 @@ const MultipleOrders = () => {
size="small"
type="number"
value={customer.quantity || ''}
onChange={(e) => handleQuantityChange(customer.customerid, e.target.value)}
onChange={(e) => handleQuantityChange(customer._rowkey, e.target.value)}
inputProps={{ min: 0 }}
sx={{ width: 64, '& .MuiOutlinedInput-root': { borderRadius: '8px', height: 30 } }}
/>
@@ -1229,7 +1517,7 @@ const MultipleOrders = () => {
placeholder="0"
onChange={(e) => {
const v = Number(e.target.value);
handleCollectionAmtChange(customer.customerid, v > 0 ? v : 0);
handleCollectionAmtChange(customer._rowkey, v > 0 ? v : 0);
}}
inputProps={{ min: 0 }}
InputProps={{ startAdornment: <InputAdornment position="start">₹</InputAdornment> }}
@@ -1237,7 +1525,7 @@ const MultipleOrders = () => {
/>
)}
</TableCell>
<TableCell sx={cellBodySx}>{customer.distance}</TableCell>
<TableCell sx={cellBodySx}>{customer.distance || '—'}</TableCell>
<TableCell align="right" sx={{ ...cellBodySx, fontWeight: 700, color: '#1e293b' }}>
₹{Number(customer?.totalcharge || 0).toFixed(2)}
</TableCell>
@@ -1477,7 +1765,7 @@ const MultipleOrders = () => {
autoFocus
fullWidth
id="input-search-header"
placeholder="Search by name, contact, or address…"
placeholder="Search by name or phone…"
value={searchCustList}
onChange={(e) => setSearchCustList(e.target.value)}
sx={{ bgcolor: 'white', borderRadius: '10px', '& .MuiOutlinedInput-input': { p: '10px 14px' } }}
@@ -1498,25 +1786,28 @@ const MultipleOrders = () => {
</DialogTitle>
<Divider />
<DialogContent sx={{ p: 2.5, bgcolor: '#fafbfc', minHeight: 400, maxHeight: { xs: 'none', sm: 600 } }}>
{customerlist?.length === 0 ? (
{filteredCustomerList?.length === 0 ? (
<Stack alignItems="center" justifyContent="center" sx={{ minHeight: 300 }}>
<Empty description="No saved customers found for this client" />
</Stack>
) : (
<Stack spacing={0}>
{customerlist?.map((customer, index) => {
const checked = dropCust.some((c) => c.customerid === customer.customerid);
{filteredCustomerList?.map((customer, index) => {
const rowkey = `sel-${customer.appcustomerid}`;
const checked = dropCust.some((c) => c._rowkey === rowkey);
return (
<FormGroup key={customer.customerid || index}>
<FormGroup key={customer.appcustomerid || index}>
<FormControlLabel
sx={{ m: 0, py: 1, px: 1.25, borderRadius: '10px', '&:hover': { bgcolor: 'rgba(24,144,255,0.04)' } }}
control={<Checkbox checked={checked} onChange={(event) => handleCheckboxChange(event, customer)} />}
label={
<Box sx={{ width: '100%' }}>
<Typography sx={{ fontWeight: 600, color: '#1e293b', fontSize: 13.5 }}>
{customer.firstname} ({customer.contactno})
{customer.name} ({customer.phone})
</Typography>
<Typography sx={{ color: '#94a3b8', fontSize: 12, mt: 0.25, fontStyle: 'italic' }}>
Address not on file — add one after selecting
</Typography>
<Typography sx={{ color: '#64748b', fontSize: 12, mt: 0.25 }}>{customer.address}</Typography>
</Box>
}
/>

View File

@@ -135,7 +135,6 @@ const OptimisedOrderPreview = () => {
});
setFinalDeliveryList(updateDeliveryAmtList);
console.log('finaldeliveryList', updateDeliveryAmtList);
}, [deliverylist]);
// ==============================|| fetchPaymentType ||============================== //
@@ -187,10 +186,19 @@ const OptimisedOrderPreview = () => {
const createFinalDeliveryMutation = useMutation({
mutationFn: finalCreatedeliveries, // for optimised delivery create
onSuccess: (data, variables) => {
console.log('data', data);
console.log('varialbles', variables);
notifyRiderMutation.mutate(rider.userfcmtoken || riderToken); // Call notifyRider after success
onSuccess: (data) => {
// rider state here is never set (no setRider call anywhere in this
// file), so rider.userfcmtoken threw on every success — also the
// wrong argument type regardless (notifyRider needs a milerprofileid,
// not an FCM token; see dispatch/Preview.js's identical fix). Notify
// every rider actually present in the committed deliveries instead.
const notifiedRiderIds = new Set();
(finaldeliveryList || []).forEach((order) => {
const riderId = order.userid;
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
notifiedRiderIds.add(String(riderId));
notifyRiderMutation.mutate(riderId);
});
if (data.status == 'accepted') {
OpenToast('Delivery Created Successfully', 'success', 2000);
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-unused-vars */
import * as React from 'react';
import { enqueueSnackbar } from 'notistack';
import { useState, useEffect, Fragment, useRef } from 'react';
import { useState, useEffect, useMemo, useCallback, Fragment, useRef } from 'react';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
@@ -29,7 +29,10 @@ import {
CircularProgress,
InputBase,
Backdrop,
TableContainer
TableContainer,
Divider,
Chip,
Checkbox
} from '@mui/material';
import {
MdAccessTime,
@@ -48,16 +51,18 @@ import {
MdReceiptLong,
MdClear,
MdNotes,
MdMyLocation
MdMyLocation,
MdOutlineFlag,
MdPerson
} from 'react-icons/md';
import { DeleteOutlined } from '@ant-design/icons';
import Loader from 'components/Loader';
import DateFilterDialog from 'components/DateFilterDialog';
import CircularLoader from 'components/CircularLoader';
import AiImage from '../../../assets/images/aiImage.png';
import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate, useLocation } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders, fetchorderscount } from '../../api/api';
import { fetchPercentageData, createAutomationDeliveries, getallriders } from '../../api/api';
import { getBookings, cancelBooking, getAdminCustomers, getBookingTrack } from 'pages/api/doormileApi';
// ============================================================================
@@ -105,9 +110,17 @@ const ROW_STATUS_META = {
};
// Top-level pill tabs. GET /admin/bookings has no documented status filter
// param, so this tab doesn't narrow the server query — it's cosmetic until
// the API supports it.
const ORDERS_STATUS_TABS = [{ idx: 0, status: 'created', label: 'Bookings', color: BRAND, icon: MdLocalShipping, countKey: 'created' }];
// param, so bookings are fetched in bulk (up to the API's pagesize cap) and
// filtered/counted client-side by row.status — see the bulk fetch + useMemo
// below. `status` values here are the raw, lowercased GET /admin/bookings
// enum (matches ROW_STATUS_META's keys), not api.js's separate generic
// pending/accepted/delivered mapping used by the Deliveries page.
const ORDERS_STATUS_TABS = [
{ idx: 0, status: 'pending_pickup', label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty },
{ idx: 1, status: 'converted_to_consignment', label: 'Assigned', color: '#6366f1', icon: MdCheckCircle },
{ idx: 2, status: 'delivered', label: 'Delivered', color: '#10b981', icon: MdCheckCircle },
{ idx: 3, status: 'cancelled', label: 'Cancelled', color: '#ef4444', icon: MdCancel }
];
// Haversine straight-line distance in km — GET /admin/bookings has no `kms`
// field, but does carry both pickup*/delivery* lat/lng, so this is computed
@@ -198,14 +211,21 @@ const Orders = () => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [tabvalue, setTabvalue] = useState(0);
const [tabstatus, setTabstatus] = useState('Created');
const [currentStatus, setCurrentStatus] = useState('created');
const [tabstatus, setTabstatus] = useState('Pending');
const [currentStatus, setCurrentStatus] = useState('pending_pickup');
// Orders selected via the checkbox column (Pending tab only) to send to
// the AI dispatch optimiser. Assign Orders used to submit every visible
// row unconditionally, including cancelled ones (no filtering existed at
// all) — this makes assignment an explicit, scoped action instead.
const [selectedOrders, setSelectedOrders] = useState([]);
const [cancelOpen, setCancelOpen] = useState(false);
const [trackBookingId, setTrackBookingId] = useState(null);
// GET /admin/bookings/:id/track — response shape isn't documented beyond
// the endpoint existing (Status: Done in jupiter2doormile.md). Rendered
// defensively below rather than assuming specific field names.
// GET /admin/bookings/:id/track — confirmed live shape (not documented in
// express-console-api.md): { booking: {...full booking incl. parcels[],
// serviceoptions[], payments[]}, assignments: [], riders: [] }. assignments/
// riders field names beyond that haven't been observed non-empty yet, so
// they're rendered defensively (see the Track Order dialog below).
const { data: trackData, isLoading: trackLoading } = useQuery({
queryKey: ['booking-track', trackBookingId],
queryFn: () => getBookingTrack(trackBookingId),
@@ -241,6 +261,7 @@ const Orders = () => {
setTabstatus(tab.label);
setCurrentStatus(tab.status);
setPage(0);
setSelectedOrders([]);
};
// React Queries
@@ -255,14 +276,6 @@ const Orders = () => {
refetchInterval: 15000
});
const { data: ordersCountData, refetch: orderscountRefetch } = useQuery({
queryKey: ['ordersCount', locationId, startdate, enddate, currentStatus, tid, locationId],
queryFn: fetchorderscount,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 15000
});
const { data: autoRiders } = useQuery({
queryKey: ['getallriders'],
queryFn: getallriders,
@@ -299,8 +312,8 @@ const Orders = () => {
autoHideDuration: 2000
});
refetchOrders();
orderscountRefetch();
percentagedataRefetch();
setSelectedOrders((prev) => prev.filter((o) => o.bookingid !== orderheaderid));
setCancelOpen(false);
} else {
enqueueSnackbar(res.message || 'Failed to cancel order', {
@@ -310,7 +323,6 @@ const Orders = () => {
});
}
} catch (err) {
console.log(err);
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to cancel order', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
@@ -319,31 +331,62 @@ const Orders = () => {
}
};
// Maps to /admin/bookings — tenantid/locationid/status/date filters used by
// the old endpoint aren't documented on this resource; pageno/pagesize are.
const fetchOrders = async ({ pageParam = 1 }) => {
const data = (await getBookings(pageParam, rowsPerPage)) || [];
return {
data,
nextPage: data.length === rowsPerPage ? pageParam + 1 : undefined
};
};
// GET /admin/bookings has no status/date/search filter params documented
// (see express-console-api.md's "Conventions") — fetched in one bulk call
// (up to the API's pagesize cap) and filtered/counted entirely client-side.
// Same approach api.js's getDeliveryStatusCounts already uses for the
// Deliveries page's chip counts, and for the same reason: cancelled orders
// used to show up mixed into every tab (there was no filtering at all —
// the single "Bookings" tab that existed before was cosmetic), and the
// Assign Orders count/submit used every visible row unconditionally.
const {
data: rowdata,
fetchNextPage,
data: allBookings = [],
isLoading: isLoadingGetOrders,
hasNextPage,
isFetchingNextPage,
refetch: refetchOrders
} = useInfiniteQuery({
queryKey: [tabstatus, startdate, enddate, page, rowsPerPage, debouncedSearch, locationId],
queryFn: fetchOrders,
getNextPageParam: (lastPage) => lastPage.nextPage,
} = useQuery({
queryKey: ['ordersBulk'],
queryFn: async () => (await getBookings(1, 1000)) || [],
refetchInterval: 15000
});
const rows = rowdata ? rowdata.pages.flatMap((p) => p.data) : [];
// Tab counts computed from the exact same data the table renders, so a
// tab's badge always matches what clicking it actually shows.
const statusCounts = useMemo(() => {
const counts = {};
allBookings.forEach((b) => {
const status = String(b.status || '').toLowerCase();
counts[status] = (counts[status] || 0) + 1;
});
return counts;
}, [allBookings]);
const filteredRows = useMemo(() => {
const q = debouncedSearch.trim().toLowerCase();
return allBookings.filter((b) => {
if (String(b.status || '').toLowerCase() !== currentStatus) return false;
if (!q) return true;
return (
b.bookingno?.toLowerCase().includes(q) ||
b.pickupaddress?.toLowerCase().includes(q) ||
b.deliveryaddress?.toLowerCase().includes(q) ||
b.notes?.toLowerCase().includes(q)
);
});
}, [allBookings, currentStatus, debouncedSearch]);
// "Load more" now reveals more of the already-fetched, already-filtered
// set instead of requesting another server page (the bulk fetch above
// already has everything) — the IntersectionObserver/scroll-triggered
// reveal below is otherwise unchanged from the previous pagination UX.
const [visibleCount, setVisibleCount] = useState(rowsPerPage);
useEffect(() => {
setVisibleCount(rowsPerPage);
}, [currentStatus, debouncedSearch, rowsPerPage]);
const rows = filteredRows.slice(0, visibleCount);
const hasNextPage = filteredRows.length > visibleCount;
const isFetchingNextPage = false;
const fetchNextPage = useCallback(() => setVisibleCount((v) => v + rowsPerPage), [rowsPerPage]);
useEffect(() => {
if (!hasNextPage) return;
@@ -384,10 +427,12 @@ const Orders = () => {
autoHideDuration: 2000,
anchorOrigin: { vertical: 'top', horizontal: 'right' }
});
orderscountRefetch();
refetchOrders();
setCreateloader(false);
navigate('/nearle/dispatch/preview', {
// Assigned bookings' status flips away from pending_pickup server-side,
// so refetching above is what makes them drop out of the Pending tab.
setSelectedOrders([]);
navigate('/doormile/dispatch/preview', {
state: {
dispatchPreviewData: data,
aiMode: 1,
@@ -411,7 +456,7 @@ const Orders = () => {
});
const handleCreateDelivery = async () => {
if (rows.length === 0) return;
if (selectedOrders.length === 0) return;
setIsLoading(true);
setCreateloader(true);
// The optimiser service (routes.workolik.com) itself is untouched, but the
@@ -420,7 +465,10 @@ const Orders = () => {
// object (see pickuplatitude/deliverylatitude/serviceoptions[0].estimatedprice
// instead). The solver's own expected input contract is unverified since
// it's a separate, unchanged service.
const deliveryData = rows.map((val) => {
// Only the operator's checked selection is submitted — this used to map
// over every visible row unconditionally, which (before status filtering
// existed) meant cancelled orders could get sent to the solver too.
const deliveryData = selectedOrders.map((val) => {
const charge = val.serviceoptions?.[0]?.estimatedprice;
return {
...val,
@@ -470,6 +518,10 @@ const Orders = () => {
}
];
// Table column count for skeleton rows / colSpan — one more than usual
// while the checkbox column is showing (Pending tab only).
const colCount = currentStatus === 'pending_pickup' ? 12 : 11;
return (
<Fragment>
{(fetchpercentageIsLoading || isLoadingGetOrders || isLoading || createloader) && (
@@ -479,7 +531,7 @@ const Orders = () => {
</>
)}
{rows.length > 0 && currentStatus === 'created' && (
{selectedOrders.length > 0 && currentStatus === 'pending_pickup' && (
<Button
variant="contained"
onClick={handleCreateDelivery}
@@ -505,7 +557,7 @@ const Orders = () => {
}}
startIcon={<MdLocalShipping size={18} />}
>
Assign Orders ({rows.length})
Assign Orders ({selectedOrders.length})
</Button>
)}
@@ -606,7 +658,7 @@ const Orders = () => {
{ORDERS_STATUS_TABS.map((t) => {
const Icon = t.icon;
const active = tabvalue === t.idx;
const count = ordersCountData?.[t.countKey] ?? 0;
const count = statusCounts[t.status] ?? 0;
return (
<Box
key={t.status}
@@ -718,6 +770,17 @@ const Orders = () => {
}
}}
>
{currentStatus === 'pending_pickup' && (
<TableCell padding="checkbox">
<Checkbox
size="small"
sx={{ p: 0.5, color: edge(BRAND), '&.Mui-checked, &.MuiCheckbox-indeterminate': { color: BRAND } }}
indeterminate={selectedOrders.length > 0 && selectedOrders.length < rows.length}
checked={rows.length > 0 && selectedOrders.length === rows.length}
onChange={(e) => setSelectedOrders(e.target.checked ? rows : [])}
/>
</TableCell>
)}
<TableCell>#</TableCell>
<TableCell>Order</TableCell>
<TableCell>Pickup</TableCell>
@@ -737,7 +800,7 @@ const Orders = () => {
rows.length === 0 &&
Array.from({ length: 10 }).map((_, idx) => (
<TableRow key={`sk-${idx}`}>
{Array.from({ length: 11 }).map((__, ci) => (
{Array.from({ length: colCount }).map((__, ci) => (
<TableCell key={ci} sx={{ borderBottom: `1px solid ${DT.divider}`, py: 0.625, px: 1 }}>
<Skeleton animation="wave" height={20} />
</TableCell>
@@ -747,7 +810,7 @@ const Orders = () => {
{!isLoadingGetOrders && rows.length === 0 && (
<TableRow>
<TableCell colSpan={11} sx={{ py: 7, borderBottom: 'none' }}>
<TableCell colSpan={colCount} sx={{ py: 7, borderBottom: 'none' }}>
<Stack alignItems="center" spacing={1.25}>
<Avatar
variant="rounded"
@@ -761,7 +824,7 @@ const Orders = () => {
>
<MdLocalShipping size={26} />
</Avatar>
<Typography sx={{ fontWeight: 700, color: DT.textPrimary, fontSize: 14 }}>No {currentStatus} orders</Typography>
<Typography sx={{ fontWeight: 700, color: DT.textPrimary, fontSize: 14 }}>No {tabstatus.toLowerCase()} orders</Typography>
<Typography sx={{ color: DT.textSecondary, fontSize: 12 }}>
{searchword ? 'Try a different keyword or clear the search.' : 'Adjust the location, status, or date range above.'}
</Typography>
@@ -793,6 +856,7 @@ const Orders = () => {
const customer = customerMap?.get(row.appcustomerid);
const kms = haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude);
const isCancellable = !['Cancelled', 'Converted_To_Consignment'].includes(row.status);
const isChecked = selectedOrders.some((o) => o.bookingid === row.bookingid);
return (
<TableRow
@@ -813,6 +877,21 @@ const Orders = () => {
}
}}
>
{currentStatus === 'pending_pickup' && (
<TableCell padding="checkbox">
<Checkbox
size="small"
sx={{ p: 0.5, color: edge(BRAND), '&.Mui-checked': { color: BRAND } }}
checked={isChecked}
onChange={(e) => {
setSelectedOrders((prev) =>
e.target.checked ? [...prev, row] : prev.filter((o) => o.bookingid !== row.bookingid)
);
}}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
<TableCell>
<Typography sx={{ fontWeight: 700, color: DT.textSecondary }}>{page * rowsPerPage + index + 1}</Typography>
</TableCell>
@@ -955,7 +1034,7 @@ const Orders = () => {
<MdMyLocation size={14} />
</IconButton>
</Tooltip>
{currentStatus === 'created' && isCancellable && (
{currentStatus === 'pending_pickup' && isCancellable && (
<Tooltip title="Cancel Order">
<IconButton
size="small"
@@ -988,7 +1067,7 @@ const Orders = () => {
{rows.length !== 0 && (
<TableRow sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}>
<TableCell colSpan={11} sx={{ borderBottom: 'none', py: 1, bgcolor: DT.surfaceAlt }}>
<TableCell colSpan={colCount} sx={{ borderBottom: 'none', py: 1, bgcolor: DT.surfaceAlt }}>
<Stack ref={loadMoreRef} direction="row" alignItems="center" justifyContent="center" spacing={1} sx={{ minHeight: 32 }}>
{isFetchingNextPage || hasNextPage ? (
<>
@@ -1070,26 +1149,158 @@ const Orders = () => {
</Dialog>
{/* ============================================= || Track Order || ============================================= */}
<Dialog open={!!trackBookingId} onClose={() => setTrackBookingId(null)} maxWidth="xs" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}>
<Dialog open={!!trackBookingId} onClose={() => setTrackBookingId(null)} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3 } }}>
<DialogTitle sx={{ fontWeight: 700 }}>Track Order</DialogTitle>
<DialogContent dividers>
{trackLoading ? (
<Stack alignItems="center" sx={{ py: 3 }}>
<CircularProgress size={28} />
</Stack>
) : trackData ? (
<Stack spacing={1.5}>
{Object.entries(trackData).map(([key, value]) => (
<Stack key={key} direction="row" justifyContent="space-between" spacing={2}>
<Typography variant="caption" sx={{ color: DT.textMuted, textTransform: 'uppercase', fontWeight: 700 }}>
{key}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'right', wordBreak: 'break-word' }}>
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
</Typography>
) : trackData?.booking ? (
(() => {
const b = trackData.booking;
const assignments = trackData.assignments || [];
const riders = trackData.riders || [];
const fmt = (d) => (d ? dayjs(d).format('DD MMM, hh:mm A') : '—');
const SectionLabel = ({ children }) => (
<Typography variant="caption" sx={{ color: DT.textMuted, textTransform: 'uppercase', fontWeight: 800, letterSpacing: 0.4 }}>
{children}
</Typography>
);
return (
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{b.bookingno || `#${b.bookingid}`}
</Typography>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
Booked {fmt(b.createdat)}
</Typography>
</Stack>
<StatusBadge status={b.status} />
</Stack>
<Divider />
<Stack spacing={1.25}>
<Stack direction="row" spacing={1} alignItems="flex-start">
<MdMyLocation size={15} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
<Stack>
<SectionLabel>Pickup</SectionLabel>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{b.pickupaddress || '—'}
</Typography>
{b.pickuppincode && (
<Typography variant="caption" sx={{ color: DT.textMuted }}>
Pincode {b.pickuppincode}
</Typography>
)}
</Stack>
</Stack>
<Stack direction="row" spacing={1} alignItems="flex-start">
<MdOutlineFlag size={15} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
<Stack>
<SectionLabel>Delivery</SectionLabel>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{b.deliveryaddress || '—'}
</Typography>
{(b.deliverypincode || b.deliverycity) && (
<Typography variant="caption" sx={{ color: DT.textMuted }}>
{[b.deliverycity, b.deliverypincode].filter(Boolean).join(' · ')}
</Typography>
)}
</Stack>
</Stack>
{b.notes && (
<Stack direction="row" spacing={1} alignItems="flex-start">
<MdNotes size={15} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
<Stack>
<SectionLabel>Notes</SectionLabel>
<Typography variant="body2">{b.notes}</Typography>
</Stack>
</Stack>
)}
</Stack>
{b.parcels?.length > 0 && (
<>
<Divider />
<Stack spacing={1}>
<SectionLabel>Parcels</SectionLabel>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{b.parcels.map((p) => (
<Chip
key={p.bookingparcelid}
size="small"
icon={<MdInventory2 size={13} />}
label={`${p.itemcategory || 'Item'} · ₹${Number(p.declaredvalue || 0).toFixed(2)}`}
sx={{ bgcolor: tint(BRAND), border: `1px solid ${edge(BRAND)}`, fontWeight: 600 }}
/>
))}
</Stack>
</Stack>
</>
)}
{b.serviceoptions?.length > 0 && (
<>
<Divider />
<Stack spacing={1}>
<SectionLabel>Service</SectionLabel>
{b.serviceoptions.map((s, i) => (
<Stack key={i} direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{s.servicetype || '—'}
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="caption" sx={{ color: DT.textMuted }}>
ETA {fmt(s.estimateddeliveryat)}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: BRAND }}>
₹{Number(s.estimatedprice || 0).toFixed(2)}
</Typography>
</Stack>
</Stack>
))}
</Stack>
</>
)}
<Divider />
<Stack spacing={1}>
<SectionLabel>Rider</SectionLabel>
{riders.length === 0 && assignments.length === 0 ? (
<Typography variant="body2" sx={{ color: DT.textSecondary }}>
Not assigned to a rider yet.
</Typography>
) : (
<Stack spacing={0.75}>
{riders.map((r, i) => (
<Stack key={i} direction="row" spacing={1} alignItems="center">
<MdPerson size={14} color={DT.textMuted} />
<Typography variant="body2">
{r.displayname || r.name || r.phone || JSON.stringify(r)}
</Typography>
</Stack>
))}
{assignments.map((a, i) => (
<Stack key={i} direction="row" justifyContent="space-between">
<Typography variant="caption" sx={{ color: DT.textMuted, wordBreak: 'break-word' }}>
{a.status || a.assignmentstatus || 'Assignment'}
</Typography>
<Typography variant="caption" sx={{ color: DT.textMuted }}>
{fmt(a.createdat || a.assignedat)}
</Typography>
</Stack>
))}
</Stack>
)}
</Stack>
</Stack>
))}
</Stack>
);
})()
) : (
<Typography variant="body2" sx={{ color: DT.textSecondary, textAlign: 'center', py: 3 }}>
No tracking data available for this order yet.