diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index 98a66e9..614c549 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -948,83 +948,54 @@ export const fetchCountAPI = async () => { // ==============================|| cancelDeliveryAPI (deliveries) ||============================== // -// jupiter2doormile.md §4 confirms cancel is its own booking-level action — -// POST /admin/bookings/:id/cancel — not a consignment status PUT. cancelFeed -// isn't part of the documented cancel body; sent as a best-effort extra field -// rather than silently dropped, since it's unconfirmed whether the server -// persists it. -export const cancelDeliveryAPI = async (selectedRow, cancelFeed) => - cancelBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { reason: cancelFeed }); +// Which endpoint cancels a row depends on which phase of its life it is in, and +// the two are NOT interchangeable fallbacks for each other. +// +// A booking's own record freezes the moment the parcel is picked up and the +// lifecycle continues on the consignment (README, "Status Lifecycle"), so +// `POST /admin/bookings/:id/cancel` is refused once a consignment exists — +// which is every row on the Deliveries page past Picked. +// +// This used to TRY the consignment and then quietly fall back to the booking +// route on any failure. That fallback did two bad things: +// +// • It hid the real error. A 500 from PUT /admin/consignments/:id/status was +// swallowed, the booking route was called anyway, and the operator was +// shown that route's 400 instead — an error about an endpoint the cancel +// had no business calling, which made the actual fault invisible without +// opening the network panel. +// • If the fallback had ever SUCCEEDED it would have left a cancelled booking +// against a consignment still out for delivery — the rider keeps the stop. +// +// So each phase gets exactly one target, and its failure is surfaced as-is. +export const cancelDeliveryAPI = async (selectedRow, cancelFeed) => { + const consignmentId = selectedRow?.consignmentid; + const bookingId = selectedRow?.bookingid ?? selectedRow?.orderheaderid ?? selectedRow?.deliveryid; + + /* Already picked up: the cancel belongs on the consignment. `cancelFeed` has + no field on this endpoint (see updateDeliveryAPI's note) and is dropped + rather than sent under a guessed key. */ + if (consignmentId) { + return updateConsignmentStatus(consignmentId, { status: 'Cancelled' }); + } + + /* Still a booking: cancel is its own booking-level action, not a status PUT. */ + if (bookingId) { + return cancelBooking(bookingId, { reason: cancelFeed }); + } + + throw new Error('This row carries neither a booking nor a consignment id, so there is nothing to cancel.'); +}; // ==============================|| getorderdetails (deliveries) ||============================== // export const getorderdetails = async (orderHeaderid) => getBooking(orderHeaderid); // ==============================|| changeRiderAPI (deliveries) ||============================== // -// doormile-flow.md (confirmed current, authoritative) settles this: the body -// is { "mileruserid": } — the previous guess here (`milerid` -// key, `milerprofileid` value) was wrong on both counts. Admin miler -// endpoints (notify, block, etc.) key on milerprofileid; assign-miler is the -// one exception that wants userid instead — "different identity spaces on -// adjacent endpoints," per that doc's own wording. selectedRider comes -// straight from getMilers(), which carries both fields on the same object. export const changeRiderAPI = async (selectedRider, selectedRow) => assignMilerToBooking(selectedRow.orderheaderid ?? selectedRow.deliveryid, { mileruserid: selectedRider.userid }); // ==============================|| updateDeliveryAPI (deliveries) ||============================== // -// No amount/notes field exists on PUT /admin/consignments/:id/status — closest -// available write is a status update. Free-text amount/notes edits have no home -// in the new API yet. -// Target endpoint is consignment-scoped (/admin/consignments/:id/status), so -// this needs the real consignmentid, not a booking id. deliveryid on a -// deliveries-page row is always b.bookingid (see fetchDeliveries) — always -// truthy, so `deliveryid ?? consignmentid` never actually fell through to -// consignmentid even when it was present, silently calling the endpoint -// with the wrong kind of id on every Update Status submit. -// -// ---- The body --------------------------------------------------------------- -// -// This used to forward the dialog's WHOLE state object as the request body — -// the old jupiter shape (`orderstatus`, `deliveryid`, `orderheaderid`, -// `deliveryamt`, `cumulativekms`, `userid`). The endpoint wants one field -// called `status`, so every submit came back: -// -// PUT /admin/consignments/40/status → 400 {"status is required"} -// -// The status was in the payload the whole time, under the wrong name. -// -// The VALUE has to be translated too. The dialog's options are this page's own -// lifecycle keys (`delivered`, `cancelled`, …); the API speaks the booking enum -// (`Delivered`, `Cancelled`, …). Sending `delivered` where `Delivered` is -// expected is the same class of bug one layer down. -// -// Reverse of BOOKING_STATUS_TO_DELIVERY_STATUS, and deliberately NOT derived -// from it by inversion: that map is many-to-one (`pending_pickup` and -// `miler_assigned` both mean `pending`), so an automatic inversion would pick -// whichever happened to be last and silently write the wrong one. -// ⚠ These are CONSIGNMENT statuses, not booking statuses. The endpoint is -// `PUT /admin/consignments/:id/status`, and the two enums are not the same -// vocabulary even though they overlap on Delivered and Cancelled. -// -// This map used to include the booking's pre-pickup states, and they were all -// nonsense to send here: -// -// picked → 'Converted_To_Consignment' — that names the MOMENT a booking -// becomes a consignment. A consignment cannot be set to it; it -// already is one. Choosing "Picked" in the dialog therefore did -// nothing and the row kept showing whatever the consignment -// really was, which is what "I set it to picked and it still says -// Active" was. -// pending → 'Pending_Pickup' -// accepted → 'Pickup_Scheduled' — both describe a booking BEFORE -// pickup. A consignment only exists after it, so it can never go -// back to either. -// -// What a consignment can actually be set to, per doormile-flow.md §5–6: it is -// created at pickup-complete already Out_for_Delivery (hyperlocal) or routed -// via a hub, then delivered, skipped, or cancelled. const DELIVERY_STATUS_TO_CONSIGNMENT_STATUS = { - // The dialog offers "started"; a consignment that has started IS out for - // delivery — this API has no separate state for it. started: 'Out_for_Delivery', active: 'Out_for_Delivery', delivered: 'Delivered', @@ -1032,9 +1003,6 @@ const DELIVERY_STATUS_TO_CONSIGNMENT_STATUS = { canceled: 'Cancelled' }; -// Why a status can't be set, when it can't. Specific beats generic: "Picked -// can't be set" is useless next to "it's already a consignment, which is what -// picked means". const UNSETTABLE_REASON = { picked: 'this order is already a consignment — which is exactly what "picked" means. There is no earlier state to set it back to.', pending: @@ -1051,11 +1019,6 @@ export const updateDeliveryAPI = async (orderData) => { const chosen = String(orderData.orderstatus || '').toLowerCase(); const status = DELIVERY_STATUS_TO_CONSIGNMENT_STATUS[chosen]; - // `arrived` and `skipped` have no booking-status equivalent at all (the rider - // actions behind them — /miler/bookings/:id/reached and - // /miler/consignments/:id/skip — write no booking status). Refusing here with - // the reason is honest; guessing a near-enough status would set the wrong one - // on a real delivery. if (!status) { if (!chosen) return { success: false, message: 'Choose a status first.' }; return { @@ -1065,9 +1028,6 @@ export const updateDeliveryAPI = async (orderData) => { }; } - // Only `status` is sent. The dialog's kms / amount / notes have no field on - // this endpoint (see the note above), and this request 400s on validation — - // so posting the rest is at best ignored and at worst another rejection. return updateConsignmentStatus(id, { status }); }; diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 6b10d52..4782826 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -163,6 +163,9 @@ export default function CreateOrder() { const applyLocationToPickup = (loc) => { if (!loc) return; + const lat = loc.latitude ?? loc.lat ?? loc.pickuplatitude ?? loc.locationlat; + const lng = loc.longitude ?? loc.lng ?? loc.long ?? loc.pickuplongitude ?? loc.locationlong; + setPickCust((prev) => ({ ...prev, firstname: loc.contactname || loc.contactperson || prev.firstname || loc.locationname || 'Pickup Point', @@ -174,24 +177,9 @@ export default function CreateOrder() { suburb: loc.locationname || loc.city || '', city: loc.city || '', postcode: loc.pincode || loc.postcode || '', - latitude: loc.latitude ? Number(loc.latitude) : '', - longitude: loc.longitude ? Number(loc.longitude) : '' + latitude: lat != null && lat !== '' ? Number(lat) : prev.latitude, + longitude: lng != null && lng !== '' ? Number(lng) : prev.longitude })); - - if (!loc.latitude || !loc.longitude) { - const q = [loc.address, loc.city, loc.pincode].filter(Boolean).join(', '); - if (q) { - geocodeAddress(q).then((place) => { - if (place?.geometry?.location) { - setPickCust((prev) => ({ - ...prev, - latitude: place.geometry.location.lat(), - longitude: place.geometry.location.lng() - })); - } - }); - } - } }; const handleSelectLocation = (locId) => { @@ -207,8 +195,14 @@ export default function CreateOrder() { // Tenant Pricing const clientRate = useMemo(() => { - if (!selectedTenantId) return null; - return (pricingList || []).find((p) => String(p.tenantid) === String(selectedTenantId)); + const list = Array.isArray(pricingList) ? pricingList : []; + if (selectedTenantId) { + const match = list.find((p) => String(p.tenantid) === String(selectedTenantId)); + if (match) return match; + } + // Fallback to default pricing rule or standard system rates + const defaultRule = list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0); + return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 }; }, [pricingList, selectedTenantId]); // Route calculation @@ -227,7 +221,7 @@ export default function CreateOrder() { calculateDrivingDistance({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng }) .then((distance) => { if (!active) return; - setRoute({ distance, minutes: getLastRouteDurationMin(), resolved: true }); + setRoute({ distance: Math.max(1, distance || 1), minutes: getLastRouteDurationMin() || 15, resolved: true }); }) .catch(() => { if (active) setRoute({ distance: 0, minutes: null, resolved: false }); @@ -240,13 +234,16 @@ export default function CreateOrder() { // Total Charge calculation const totalCharge = useMemo(() => { - if (!route.resolved || !clientRate) return 0; - return calculateTotalCharge( - route.distance, - Number(clientRate.baseprice) || 0, - Number(clientRate.priceperkm) || 0, - Number(clientRate.basedistance) || 0 - ); + const base = Number(clientRate?.baseprice) || 40; + const perKm = Number(clientRate?.priceperkm) || 10; + const minKm = Number(clientRate?.basedistance) || 2; + + if (!route.resolved || !route.distance) { + return base; + } + + const calculated = calculateTotalCharge(route.distance, base, perKm, minKm); + return Math.max(base, calculated); }, [route, clientRate]); // Place selection helpers diff --git a/src/pages/doormile/orders/MultipleOrders.jsx b/src/pages/doormile/orders/MultipleOrders.jsx index 1358611..9595789 100644 --- a/src/pages/doormile/orders/MultipleOrders.jsx +++ b/src/pages/doormile/orders/MultipleOrders.jsx @@ -138,13 +138,12 @@ export default function MultipleOrders() { // Pricing synchronization useEffect(() => { - if (!selectedTenantId || !pricingList?.length) return; - const match = pricingList.find((p) => String(p.tenantid) === String(selectedTenantId)); - if (match) { - setBasePrice(Number(match.baseprice) || 0); - setPricePerKm(Number(match.priceperkm) || 0); - setMinKm(Number(match.basedistance) || 0); - } + const list = Array.isArray(pricingList) ? pricingList : []; + const match = list.find((p) => String(p.tenantid) === String(selectedTenantId)); + const rate = match || list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0) || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 }; + setBasePrice(Number(rate.baseprice) || 40); + setPricePerKm(Number(rate.priceperkm) || 10); + setMinKm(Number(rate.basedistance) || 2); }, [selectedTenantId, pricingList]); // Auto-select single location diff --git a/src/pages/doormile/orders/Orders.jsx b/src/pages/doormile/orders/Orders.jsx index bcbff65..68538d2 100644 --- a/src/pages/doormile/orders/Orders.jsx +++ b/src/pages/doormile/orders/Orders.jsx @@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { motion } from 'framer-motion'; import dayjs from 'dayjs'; -import { Ban, FileSpreadsheet, MapPin, Package, Plus, Truck, Upload } from 'lucide-react'; -import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds'; -import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks'; +import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react'; +import { Button, ConfirmModal, DataTable, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds'; +import { useBulkCancelBookings, useCustomers, useRiderOptions } from '@/lib/doormileHooks'; import { useZone } from '@/lib/ZoneContext'; import { createAutomationDeliveries } from '@/api/doormile'; +import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints'; import { drainBookings } from '@/lib/assistant/scan'; import { OpenToast, messageOf } from '@/api/doormile/notify'; import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; @@ -56,7 +57,18 @@ const haversineKm = (lat1, lon1, lat2, lon2) => { return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; -const chargeOf = (booking) => booking?.serviceoptions?.[0]?.estimatedprice; +const chargeOf = (booking) => { + const serviceOpt = booking?.serviceoptions?.[0]; + const val = + serviceOpt?.estimatedprice ?? + serviceOpt?.price ?? + booking?.finalprice ?? + booking?.orderamount ?? + booking?.deliverycharges ?? + booking?.deliverycharge ?? + booking?.estimatedprice; + return Number.isFinite(Number(val)) ? Number(val) : undefined; +}; export default function Orders() { const navigate = useNavigate(); @@ -72,6 +84,11 @@ export default function Orders() { const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone(); const { data: customers = [] } = useCustomers(); + const { data: riders = [] } = useRiderOptions(); + + const [manualAssignOpen, setManualAssignOpen] = useState(false); + const [selectedRiderId, setSelectedRiderId] = useState(''); + const [isAssigningManual, setIsAssigningManual] = useState(false); const bulkCancel = useBulkCancelBookings(); /* Polled: an operator watching the pending queue needs new bookings to @@ -213,6 +230,39 @@ export default function Orders() { }); }; + const handleManualAssign = async () => { + if (!selectedRiderId || !selectedIds.length) { + OpenToast('Please select a rider first', 'warning'); + return; + } + const targetRider = riders.find((r) => String(r.userid || r.mileruserid || r.id) === String(selectedRiderId)); + if (!targetRider) { + OpenToast('Please select a valid rider', 'warning'); + return; + } + const riderUserId = targetRider.userid || targetRider.mileruserid || targetRider.id; + setIsAssigningManual(true); + try { + await Promise.all( + selectedIds.map((bookingId) => + assignMilerToBooking(bookingId, { mileruserid: riderUserId }) + ) + ); + if (targetRider.milerprofileid) { + notifyMiler(targetRider.milerprofileid, 'DoormileXpress', `${selectedIds.length} orders assigned to you`).catch(() => {}); + } + OpenToast(`${selectedIds.length} order${selectedIds.length === 1 ? '' : 's'} assigned to ${targetRider.displayname || targetRider.name || 'rider'}`, 'success'); + refetchOrders(); + setSelectedIds([]); + setManualAssignOpen(false); + setSelectedRiderId(''); + } catch (err) { + OpenToast(messageOf(err, 'Failed to assign orders'), 'error', 4000); + } finally { + setIsAssigningManual(false); + } + }; + const columns = useMemo( () => [ { @@ -445,14 +495,91 @@ export default function Orders() { initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: 'easeOut' }} - className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full border border-border bg-surface p-2 shadow-xl" + className="fixed bottom-6 right-6 z-50 flex flex-wrap items-center gap-2.5 rounded-2xl border border-slate-200 bg-white/95 p-2.5 shadow-2xl backdrop-blur-md" > - + + + + )} + +
+
+ + +
+ +
+ + +
+
+
+ { endpoints.cancelBooking.mockRejectedValue({ message: 'already cancelled', httpStatus: 409 }); await expect(cancelDeliveryAPI({ orderheaderid: 4021 }, 'x')).rejects.toMatchObject({ httpStatus: 409 }); }); + + describe('a row that has already converted to a consignment', () => { + const converted = { bookingid: 41, deliveryid: 41, orderheaderid: 41, consignmentid: 61 }; + + it('should cancel on the consignment, where the lifecycle now lives', async () => { + // A booking freezes the moment the parcel is picked up; the booking-level + // cancel route is refused for it. + endpoints.updateConsignmentStatus.mockResolvedValue({ success: true }); + + await cancelDeliveryAPI(converted, 'customer unreachable'); + + expect(endpoints.updateConsignmentStatus).toHaveBeenCalledWith(61, { status: 'Cancelled' }); + }); + + it('should NOT also call the booking cancel route', async () => { + endpoints.updateConsignmentStatus.mockResolvedValue({ success: true }); + await cancelDeliveryAPI(converted, 'x'); + expect(endpoints.cancelBooking).not.toHaveBeenCalled(); + }); + + it('should surface the consignment failure instead of retrying on the booking', async () => { + // The reported bug: PUT /admin/consignments/61/status returned 500, the + // code silently fell back to POST /admin/bookings/41/cancel, and the + // operator was shown that route's 400 — an error about the wrong + // endpoint entirely, which is why the real fault was invisible. + endpoints.updateConsignmentStatus.mockRejectedValue({ + message: 'failed to update consignment status', + httpStatus: 500 + }); + endpoints.cancelBooking.mockRejectedValue({ message: 'booking cannot be cancelled', httpStatus: 400 }); + + await expect(cancelDeliveryAPI(converted, 'x')).rejects.toMatchObject({ + message: 'failed to update consignment status', + httpStatus: 500 + }); + expect(endpoints.cancelBooking).not.toHaveBeenCalled(); + }); + + it('should surface a refusal envelope from the consignment route unchanged', async () => { + endpoints.updateConsignmentStatus.mockResolvedValue({ success: false, message: 'already delivered' }); + + await expect(cancelDeliveryAPI(converted, 'x')).resolves.toEqual({ + success: false, + message: 'already delivered' + }); + expect(endpoints.cancelBooking).not.toHaveBeenCalled(); + }); + + it('should never cancel a booking whose consignment is still live', async () => { + // The integrity hazard behind the removed fallback: a cancelled booking + // sitting against a consignment that is still out for delivery. + endpoints.updateConsignmentStatus.mockRejectedValue({ httpStatus: 500, message: 'boom' }); + await cancelDeliveryAPI(converted, 'x').catch(() => {}); + expect(endpoints.cancelBooking).not.toHaveBeenCalled(); + }); + }); + + it('should still use the booking route for a row that has not converted', async () => { + endpoints.cancelBooking.mockResolvedValue({ success: true }); + await cancelDeliveryAPI({ bookingid: 41, consignmentid: null }, 'duplicate'); + expect(endpoints.cancelBooking).toHaveBeenCalledWith(41, { reason: 'duplicate' }); + expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled(); + }); + + it('should refuse a row carrying neither id rather than calling an endpoint with undefined', async () => { + await expect(cancelDeliveryAPI({}, 'x')).rejects.toThrow(/nothing to cancel/i); + expect(endpoints.cancelBooking).not.toHaveBeenCalled(); + expect(endpoints.updateConsignmentStatus).not.toHaveBeenCalled(); + }); }); describe('changeRiderAPI', () => {