From 6585a8da003944154697b1865e8a5232d7932fec Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 22 Sep 2026 16:56:10 +0530 Subject: [PATCH] updates on the ui changes and new changes as well --- src/components/ds/DatePicker.jsx | 15 +- src/lib/dateRange.js | 22 + src/lib/dispatchPreview.js | 232 ++++++++++- src/pages/doormile/deliveries/Deliveries.jsx | 24 +- src/pages/doormile/dispatch/Dispatch.css | 72 ++++ src/pages/doormile/dispatch/Dispatch.jsx | 139 ++++++- src/pages/doormile/dispatch/Preview.jsx | 320 +++++---------- src/pages/doormile/orders/Orders.jsx | 79 +--- src/pages/doormile/settings/Settings.jsx | 9 +- .../agentStudio/AgentConfigAndInsights.jsx | 228 +++++++++++ .../settings/agentStudio/AgentPlayground.jsx | 202 ++++++++++ .../agentStudio/AgentScopeNavigator.jsx | 289 +++++++++++++ .../settings/agentStudio/AgentStudio.jsx | 213 ++++++++++ .../settings/agentStudio/CreateSkillModal.jsx | 227 +++++++++++ .../settings/agentStudio/SkillCard.jsx | 150 +++++++ .../settings/agentStudio/SkillCardsGrid.jsx | 238 +++++++++++ .../agentStudio/SkillDetailsDrawer.jsx | 247 ++++++++++++ .../settings/agentStudio/ToolRegistryView.jsx | 198 +++++++++ .../settings/agentStudio/agentRegistryData.js | 241 +++++++++++ tests/ds/datePicker.test.jsx | 124 ++++++ tests/integration/agentStudio.test.jsx | 204 ++++++++++ tests/lib/dateRange.test.js | 54 +++ tests/lib/dispatchPreviewLiveRun.test.js | 211 ++++++++++ tests/lib/dispatchPreviewUnassigned.test.js | 378 ++++++++++++++++++ 24 files changed, 3770 insertions(+), 346 deletions(-) create mode 100644 src/lib/dateRange.js create mode 100644 src/pages/doormile/settings/agentStudio/AgentConfigAndInsights.jsx create mode 100644 src/pages/doormile/settings/agentStudio/AgentPlayground.jsx create mode 100644 src/pages/doormile/settings/agentStudio/AgentScopeNavigator.jsx create mode 100644 src/pages/doormile/settings/agentStudio/AgentStudio.jsx create mode 100644 src/pages/doormile/settings/agentStudio/CreateSkillModal.jsx create mode 100644 src/pages/doormile/settings/agentStudio/SkillCard.jsx create mode 100644 src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx create mode 100644 src/pages/doormile/settings/agentStudio/SkillDetailsDrawer.jsx create mode 100644 src/pages/doormile/settings/agentStudio/ToolRegistryView.jsx create mode 100644 src/pages/doormile/settings/agentStudio/agentRegistryData.js create mode 100644 tests/ds/datePicker.test.jsx create mode 100644 tests/integration/agentStudio.test.jsx create mode 100644 tests/lib/dateRange.test.js create mode 100644 tests/lib/dispatchPreviewLiveRun.test.js create mode 100644 tests/lib/dispatchPreviewUnassigned.test.js diff --git a/src/components/ds/DatePicker.jsx b/src/components/ds/DatePicker.jsx index 42fbb22..1cd78eb 100644 --- a/src/components/ds/DatePicker.jsx +++ b/src/components/ds/DatePicker.jsx @@ -98,12 +98,19 @@ export function DatePicker({ return placeholder; }, [parsed, placeholder]); - // Commit a date or preset + // Commit a date or preset. + // + // `finally`, because a consumer whose handler throws used to leave the + // calendar open on screen: setOpen(false) sat after the call and never ran, + // so clicking "This Month" looked like the picker had frozen. The error is + // deliberately still allowed to propagate — swallowing it would hide the + // consumer's bug — but the picker keeps its own invariant either way. const commit = (nextVal) => { - if (onChange) { - onChange(nextVal); + try { + if (onChange) onChange(nextVal); + } finally { + setOpen(false); } - setOpen(false); }; // Day scrubbing (< and >) diff --git a/src/lib/dateRange.js b/src/lib/dateRange.js new file mode 100644 index 0000000..38d78e5 --- /dev/null +++ b/src/lib/dateRange.js @@ -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 || '' }; +}; diff --git a/src/lib/dispatchPreview.js b/src/lib/dispatchPreview.js index 3876a67..c287135 100644 --- a/src/lib/dispatchPreview.js +++ b/src/lib/dispatchPreview.js @@ -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; +}; diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx index 2bbd5d1..dea9c33 100644 --- a/src/pages/doormile/deliveries/Deliveries.jsx +++ b/src/pages/doormile/deliveries/Deliveries.jsx @@ -15,6 +15,7 @@ import { useZone } from '@/lib/ZoneContext'; import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket'; import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp'; import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; +import { toDateRange } from '@/lib/dateRange'; import { pickupSourceTypeLabel } from '@/lib/orderFlow'; import { summariseRouting } from '@/lib/routingSummary'; @@ -83,11 +84,7 @@ export default function Deliveries() { const [cancelRow, setCancelRow] = useState(null); const [cancelReason, setCancelReason] = useState(''); - const dateParams = useMemo(() => { - if (!selectedDate || selectedDate === 'all') return { from: '', to: '' }; - if (typeof selectedDate === 'string') return { from: selectedDate, to: selectedDate }; - return { from: selectedDate.from || '', to: selectedDate.to || '' }; - }, [selectedDate]); + const dateParams = useMemo(() => toDateRange(selectedDate), [selectedDate]); const { data, isLoading, isFetching } = useDeliveries({ page, @@ -400,11 +397,18 @@ export default function Deliveries() { { - if (d) { - const s = dayjs(d).format('YYYY-MM-DD'); - setDateParams({ from: s, to: s }); - setSelectedDate(s); - } + if (!d) return; + // Store exactly what the picker emits. It speaks three shapes + // — 'YYYY-MM-DD', { from, to } for the range presets, and + // 'all' — and `dateParams` below already reads all three. + // Forcing every one through dayjs().format() turned both the + // range presets and All Dates into the string "Invalid Date". + // + // The setter that used to be called here first, setDateParams, + // does not exist: `dateParams` is derived, not state. The + // ReferenceError it threw ran before setSelectedDate, which is + // why picking a date did nothing whatsoever. + setSelectedDate(d); }} /> - + + +
+ {/* Foundation Model Choice */} +
+ + +
+ + {/* Temperature Slider */} +
+
+ + {temperature} +
+ setTemperature(parseFloat(e.target.value))} + className="w-full accent-[#C8102E] cursor-pointer" + /> +
+ 0.0 (Strictly Deterministic) + 1.0 (Creative) +
+
+
+ + {/* System Prompt Instruction */} +
+ +