updates on the ui changes and new changes as well

This commit is contained in:
2026-09-22 16:56:10 +05:30
parent 16278d3893
commit 6585a8da00
24 changed files with 3770 additions and 346 deletions

22
src/lib/dateRange.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* The DatePicker's value, as a `{ from, to }` range.
*
* `components/ds/DatePicker` emits three different shapes and a page has to
* handle all three: `'YYYY-MM-DD'` for a single day (grid click, Today,
* Yesterday, the prev/next steppers), `{ from, to }` for the range presets
* (Last 7 Days, This Month), and the string `'all'` for no filter.
*
* Coercing everything through `dayjs(value).format(...)` looks like a
* normalisation and is not: `dayjs({ from, to })` and `dayjs('all')` are both
* Invalid Date, so the range presets and All Dates silently became the literal
* string "Invalid Date" and every row was filtered out against it.
*
* An empty bound means unbounded on that side, which is what the API layer's
* `inRange` already expects.
*/
export const toDateRange = (value) => {
if (!value || value === 'all') return { from: '', to: '' };
if (typeof value === 'string') return { from: value, to: value };
if (typeof value !== 'object') return { from: '', to: '' };
return { from: value.from || '', to: value.to || '' };
};

View File

@@ -11,6 +11,15 @@
* module-local; the logic is unchanged.
*/
/** The rider key a refused order is filed under. Dispatch's rider map skips
* this key explicitly, which is what keeps it out of the miler count. */
export const UNASSIGNED_RIDER_ID = 'unassigned';
// The id an order is known by, whichever spelling the service used. Shared so
// that an order the board is showing can always be found again in the list it
// came from — the refusal lists carry no `orderid` of their own.
const orderKey = (o) => o?.orderid || o?.bookingno || o?.bookingid || o?.order_id || o?.booking_id || o?.id || null;
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
export const extractRiders = (previewData) => {
@@ -24,6 +33,10 @@ export const extractRiders = (previewData) => {
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
// `withUnassignedZones` files refused orders under a synthetic rider so the
// board can draw them. This list becomes the reconcile and commit payload,
// and a bucket is not a miler — sending it would post `userid: NaN`.
if (String(riderId) === UNASSIGNED_RIDER_ID) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
@@ -103,13 +116,43 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
}
// 2) Move within zones[].riders[].orders[]
// A reply can carry refusals with no zones at all (the empty-input shape has
// no `zones` key). Give it an empty one so the search below still runs —
// otherwise the board would offer a rider picker for an order this function
// could never find, and report success without moving anything.
if (!Array.isArray(next.zones) && Array.isArray(next.unassigned_orders) && next.unassigned_orders.length) {
next.zones = [];
}
if (Array.isArray(next.zones)) {
let movedOrder = null;
let homeZoneIdx = -1;
// The order may have no rider at all. The optimiser files the ones it
// refused in `unassigned_orders[]`, and picking a miler for one of those
// from the board is exactly how an operator recovers a failed run — so
// that list has to be searched too, and the order taken out of it, or the
// "N unassigned" banner would never fall as they work through them.
const takeFromRefused = (list, zi) => {
if (!Array.isArray(list)) return false;
// An id-less row must not match an id-less lookup — that would move an
// arbitrary order instead of the one the operator picked.
const ui = list.findIndex((o) => {
const k = orderKey(o);
return k != null && String(k) === String(orderId);
});
if (ui === -1) return false;
movedOrder = list[ui];
list.splice(ui, 1);
if (zi != null) homeZoneIdx = zi;
return true;
};
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
const zone = next.zones[zi];
if (!Array.isArray(zone.riders)) continue;
if (!Array.isArray(zone.riders)) {
takeFromRefused(zone.unassigned_orders, zi);
continue;
}
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
const r = zone.riders[ri];
if (!Array.isArray(r.orders)) continue;
@@ -128,11 +171,18 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
}
}
}
if (!movedOrder) takeFromRefused(zone.unassigned_orders, zi);
}
// A refusal the service reported at the top level rather than per zone.
if (!movedOrder) takeFromRefused(next.unassigned_orders, null);
if (movedOrder) {
const updated = {
...movedOrder,
// A refused order carries no `orderid` of its own — it was never in the
// rider tree. Stamp it now so every reader downstream, including the
// commit payload, keys it the same way the board did.
orderid: movedOrder.orderid || orderKey(movedOrder) || orderId,
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
@@ -151,8 +201,19 @@ export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderN
break;
}
}
if (!placed && homeZoneIdx >= 0) {
next.zones[homeZoneIdx].riders.push({
if (!placed) {
// Land it in the zone it came from. A zone that only ever held
// refusals has no `riders` array at all, and an order reported at the
// top level has no home zone — neither may end with the order being
// dropped on the floor, which is what returning here without placing
// it would do.
if (homeZoneIdx < 0) {
next.zones.push({ zone_name: movedOrder.zone_name || 'Unzoned', riders: [] });
homeZoneIdx = next.zones.length - 1;
}
const home = next.zones[homeZoneIdx];
home.riders = home.riders || [];
home.riders.push({
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
@@ -272,3 +333,168 @@ export function computeDeliveryAmounts(list) {
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}
/**
* The orders the optimiser could NOT place, and why.
*
* These were being dropped on the floor. `normalizePreviewData` walks
* `zones[].riders[].orders[]` and `details[]`; `extractRiders` returns early
* on a null rider id. An order the optimiser refused appears in neither — it
* sits in `zones[].unassigned_orders[]`, which nothing in this file read.
*
* The result was the failure this was written for: the optimiser answers
*
* { code: 200, status: true,
* zones: [{ active_riders_count: 0, riders: [],
* unassigned_orders: [{ …the order…,
* unassigned_reason: "No riders found (check partner online
* status)." }] }] }
*
* — a complete refusal, carrying its own explanation — and the operator got a
* green "Orders optimised" toast over an empty board. The reason was in the
* payload the whole time.
*
* Reads the top level too: the service puts them under each zone today, but
* the empty-input response shape (`details: {}`, no zones) shows the envelope
* is not fixed, and an order that reaches neither place is an order nobody
* ever hears about again.
*/
export const extractUnassigned = (previewData) => {
if (!previewData) return [];
const out = [];
const seen = new Set();
const take = (orders, zoneName) => {
(Array.isArray(orders) ? orders : []).forEach((o, idx) => {
const id = String(orderKey(o) || `unassigned-${idx}`);
if (seen.has(id)) return;
seen.add(id);
out.push({
...o,
orderid: id,
zone_name: o?.zone_name || zoneName || null,
// One phrase, whichever spelling the service used. Never invented: an
// order with no stated reason says so, because "no reason given" is
// itself worth seeing when it happens to every row.
unassigned_reason: o?.unassigned_reason || o?.reason || o?.message || 'No reason given'
});
});
};
(Array.isArray(previewData.zones) ? previewData.zones : []).forEach((z) =>
take(z?.unassigned_orders, z?.zone_name)
);
take(previewData.unassigned_orders, null);
return out;
};
/**
* What actually happened, in numbers — for the toast and the banner.
*
* `code: 200` is not success here. The optimiser returns 200 whether it
* placed every order or none, so the caller has to count. Reporting "Orders
* optimised" off the HTTP status is what turned a total failure into a green
* tick.
*/
export const assignmentSummary = (previewData) => {
const riders = extractRiders(previewData);
const unassigned = extractUnassigned(previewData);
const assigned = riders.reduce((n, r) => n + (r.orders?.length || 0), 0);
// Distinct reasons, most common first — one line explains a whole failed
// run, and in practice every row carries the same reason.
const counts = new Map();
unassigned.forEach((o) => counts.set(o.unassigned_reason, (counts.get(o.unassigned_reason) || 0) + 1));
const reasons = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([reason, count]) => ({ reason, count }));
return {
assigned,
unassigned: unassigned.length,
total: assigned + unassigned.length,
riders: riders.length,
reasons,
// True when the optimiser placed nothing at all but was handed work.
failed: assigned === 0 && unassigned.length > 0
};
};
/**
* Put the refused orders back on the board.
*
* The board renders `zones[].riders[].orders[]` and nothing else, so a reply
* whose orders are all in `unassigned_orders[]` produced an empty screen —
* "No orders in Afternoon Batch", zero zones, zero pins — over a response
* that named the zone, counted the orders and gave the reason. The operator
* was shown less than the optimiser said.
*
* Each refused order is filed under its zone as an `unassigned` rider bucket.
* That is not a new shape: the live-data path in Dispatch already synthesises
* exactly this for a row with no rider, and both rider-map loops there skip
* the `unassigned` key by name — so folding these in makes the zone, its
* order count and its pins appear without ever inventing a miler.
*
* Returns the input untouched when there is nothing to fold, and is
* idempotent: re-folding replaces the bucket rather than appending a second.
*/
export const withUnassignedZones = (previewData) => {
if (!previewData) return previewData;
const rows = extractUnassigned(previewData);
if (!rows.length) return previewData;
const byZone = new Map();
rows.forEach((o) => {
const name = o.zone_name || 'Unzoned';
if (!byZone.has(name)) byZone.set(name, []);
byZone.get(name).push(o);
});
const zones = (Array.isArray(previewData.zones) ? previewData.zones : []).map((z) => ({ ...z }));
byZone.forEach((orders, name) => {
let zone = zones.find((z) => (z.zone_name || 'Unzoned') === name);
if (!zone) {
zone = { zone_name: name, riders: [] };
zones.push(zone);
}
const bucket = {
rider_id: UNASSIGNED_RIDER_ID,
rider_name: 'Unassigned',
// step/trip only exist so the order cards and step chips have something
// to number by; they carry no routing meaning for an unplaced order.
orders: orders.map((o, idx) => ({
...o,
step: o.step || idx + 1,
trip_number: o.trip_number || 1
}))
};
zone.riders = [
...(zone.riders || []).filter((r) => String(r.rider_id ?? r.userid) !== UNASSIGNED_RIDER_ID),
bucket
];
});
return { ...previewData, zones };
};
/**
* Does an edit change a route the solver planned?
*
* Reconcile re-sequences a rider's stops after the operator rearranges them,
* and Assign Orders stays blocked until it succeeds. That is right for an
* edit inside the solver's plan, and a dead end for an order the solver
* REFUSED: such an order was never sequenced, and `reconcile-steps` is the
* same external service whose rider pool came back empty — a miler it has
* never heard of will not appear in its response, the dirty flag never
* clears, and the order can never be dispatched at all.
*
* Narrow on purpose. False only when the order was in the refusal list AND
* the miler chosen has no other stops. Dropping a refused order into an
* existing route does change that route's sequence, and still reconciles.
*/
export const editNeedsReconcile = (before, after, { orderId, newRiderId }) => {
const wasRefused = extractUnassigned(before).some((o) => String(o.orderid) === String(orderId));
if (!wasRefused) return true;
const stops = extractRiders(after).find((r) => String(r.rider_id) === String(newRiderId))?.orders?.length ?? 0;
return stops > 1;
};