updates on the api integration and the design changes on the whole website
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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's name & phone for {pickordrop === 1 ? 'Pickup' : 'Drop'} — address isn'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
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user