From e1cc8742188ce4b7046f831ce94b2e6efd52fa84 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 20 Aug 2026 12:00:12 +0530 Subject: [PATCH] updates on the ai bot and assigned by chatbot --- .../nearle_components/PageLayout.js | 21 +- src/pages/api/api.js | 137 +++++++++-- src/pages/nearle/assistant/CLAUDE.md | 18 +- .../nearle/assistant/DoormileAI/AIMessage.js | 6 +- .../nearle/assistant/DoormileAI/AIPanel.js | 152 ++++++++++++- src/pages/nearle/assistant/assignActions.js | 214 ++++++++++++++++++ src/pages/nearle/assistant/assignFlow.js | 111 +++++++++ .../nearle/assistant/bulkOrderActions.js | 11 +- src/pages/nearle/assistant/intents.js | 35 ++- src/pages/nearle/deliveries/deliveries.js | 9 +- src/pages/nearle/dispatch/CLAUDE.md | 6 + src/pages/nearle/dispatch/Dispatch.js | 13 +- .../nearle/dispatch/ProfitabilitySection.js | 11 +- src/pages/nearle/orders/CreateOrder.css | 172 ++++---------- src/pages/nearle/orders/createorder1.js | 114 +++++----- src/pages/nearle/orders/multipleOrders.js | 7 +- src/themes/dt/primitives.js | 5 + 17 files changed, 794 insertions(+), 248 deletions(-) create mode 100644 src/pages/nearle/assistant/assignActions.js create mode 100644 src/pages/nearle/assistant/assignFlow.js diff --git a/src/components/nearle_components/PageLayout.js b/src/components/nearle_components/PageLayout.js index 32aea4a..73bcc10 100644 --- a/src/components/nearle_components/PageLayout.js +++ b/src/components/nearle_components/PageLayout.js @@ -5,6 +5,8 @@ import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; import { Divider } from '@astryxdesign/core/Divider'; +import { DT } from 'themes/dt/tokens'; + // ==============================|| PAGE LAYOUT PRIMITIVES ||============================== // // // The operator-page design language, in one place. @@ -169,12 +171,29 @@ FilterToolbar.propTypes = { // bottom of the screen. A table inside then only needs `maxHeight="100%"` — // no viewport arithmetic, and it re-measures on every resize for free. // --------------------------------------------------------------------------- +// The one place the table container's outline is decided, for all 17 pages +// that use DataCard. It is stated explicitly rather than inherited so the +// result is deterministic: Astryx has two neutral border tokens +// (`--color-border` at ~10% alpha and `--color-border-emphasized` at #CCD3DB) +// and which one a Card resolves to depends on its elevation and variant. A +// table frame that changes weight depending on that is exactly the +// inconsistency this is meant to remove. +// +// DT.borderSubtle (#e2e8f0) is the existing design-system neutral — no new +// colour is introduced, and it is the same value the sticky table header and +// the row separators use, so no internal line is ever darker than the frame. +export const TABLE_FRAME = `1px solid ${DT.borderSubtle}`; + export function DataCard({ toolbar, tabs, children, fill = false }) { return ( {toolbar} {toolbar && } diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 3d3a5d7..c478bd8 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -14,6 +14,7 @@ import { getHubs, getBookings, getBooking, + getConsignments, assignMilerToBooking, cancelBooking, updateConsignmentStatus, @@ -94,6 +95,19 @@ export const mapBookingStatusToDeliveryStatus = (status) => { return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key; }; +// A consignment's own status, when this booking has one and it looks like a +// status. `GET /admin/consignments` has no documented response schema, so this +// reads defensively: only a non-empty string on one of the plausible field +// names counts, and anything else returns undefined so the caller keeps using +// the booking's status. +const consignmentStatusFor = (booking, consignmentMap) => { + if (!booking?.consignmentid || !consignmentMap?.size) return undefined; + const record = consignmentMap.get(String(booking.consignmentid)); + if (!record) return undefined; + const raw = record.status ?? record.consignmentstatus ?? record.currentstatus; + return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; +}; + // Haversine straight-line distance in km — used wherever a resource carries // lat/lng but no road-distance field (bookings, and by extension deliveries). const haversineKm = (lat1, lon1, lat2, lon2) => { @@ -565,11 +579,7 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // parameter is documented in express-console-api.md and guessing one risks a // silent 400 or, worse, a silently-ignored filter), so the range is applied // client-side below, after the rows are normalised. - // queryKey[11] is an OPT-IN date basis. Four pages share this function - // (deliveries, Dispatch, reports/ordersDetails, reports/profitability) and - // three of them genuinely want "created in this window", so the default is - // unchanged and only the Deliveries page passes 'activity'. - const [, , , , startdate, enddate, rowsPerPage, , , , , dateBasis] = queryKey; + const [, , , , startdate, enddate, rowsPerPage] = queryKey; // Unlike the 3 joins below (customers/milers/tenants — each individually // guarded so a failed join just degrades a display field, not the whole // page), a failed bookings call is the one thing this function can't @@ -582,11 +592,29 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { OpenToast(err.response?.data?.message || err.message || 'Failed to load deliveries', 'error', 2000); return { rows: [], nextPage: undefined }; } - const [customers, milers, tenants] = await Promise.all([ + // Consignments are joined for ONE reason: once a booking becomes a + // consignment, its lifecycle continues on the CONSIGNMENT record and the + // booking's own `status` stops moving. `PUT /admin/consignments/:id/status` + // — the Update Status dialog — writes there, so the write succeeded, the + // toast said so, and this page went on showing the booking's stale + // `Converted_To_Consignment` because that is the only field it read. + // + // Guarded like the other three joins: if the call fails, or the response + // carries no recognisable status, the row falls back to the booking's status + // and behaviour is exactly what it was before this join existed. + const [customers, milers, tenants, consignments] = await Promise.all([ getAdminCustomers().catch(() => []), getMilers().catch(() => []), - getAdminTenants().catch(() => []) + getAdminTenants().catch(() => []), + getConsignments().catch(() => []) ]); + // The id field on a consignment record has never been captured, so both + // plausible names are indexed rather than guessing one. + const consignmentMap = new Map(); + (consignments || []).forEach((c) => { + const id = c?.consignmentid ?? c?.id; + if (id != null) consignmentMap.set(String(id), c); + }); const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c])); const milerMap = new Map((milers || []).map((m) => [m.userid ?? m.milerid, m])); const tenantMap = new Map((tenants || []).map((t) => [t.tenantid, t])); @@ -680,7 +708,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // remains fine to DISPLAY assigntime as a "last updated" stamp, which is // all the reports use it for. assigntime: b.updatedat, - orderstatus: mapBookingStatusToDeliveryStatus(b.status), + // The consignment's status WINS when there is one. That is the record + // the rider app and the Update Status dialog both advance; the booking's + // status is frozen at Converted_To_Consignment from pickup onwards. + // Falls back to the booking whenever the consignment is absent or carries + // nothing status-shaped — never invents a state. + orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status), droplat: b.deliverylatitude, droplon: b.deliverylongitude }; @@ -699,22 +732,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // A missing/blank bound means "unbounded on that side", which preserves the // old behaviour for any caller that doesn't pass real dates. // - // 'activity' additionally keeps a row whose LAST UPDATE falls in the window. - // Scoping the Deliveries page purely by creation date meant an order created - // yesterday and picked or delivered today was invisible today — which is - // exactly why every tab past Accepted read 0 while the day's fresh orders - // filled Pending and Accepted. A delivery board has to show what is moving - // now, not only what was booked now. - const dayOf = (value) => { - const t = parseDoormileTimestamp(value); - return t.isValid() ? t.format('YYYY-MM-DD') : null; - }; - + // ⛔ An "activity" basis — also admitting a row whose `assigntime` + // (== `updatedat`) falls in the window — was tried and REVERTED. It let an + // order created yesterday evening and merely touched today onto today's + // board, but batch bucketing reads `orderdate`, so that row landed in + // Evening Batch. The live result was "Evening 6" at 10:41 in the morning on a + // day with no orders created at all. Admitting a row on one timestamp while + // bucketing it on another cannot produce an honest batch count; if + // carried-over work needs to be visible it needs its own bucket, not a + // time-of-day wave it does not belong to. const inRange = (row) => { if (!startdate && !enddate) return true; - const days = [dayOf(row.orderdate), dateBasis === 'activity' ? dayOf(row.assigntime) : null].filter(Boolean); - if (!days.length) return false; - return days.some((day) => (!startdate || day >= String(startdate)) && (!enddate || day <= String(enddate))); + const t = parseDoormileTimestamp(row.orderdate); + if (!t.isValid()) return false; + const day = t.format('YYYY-MM-DD'); + if (startdate && day < String(startdate)) return false; + if (enddate && day > String(enddate)) return false; + return true; }; return { @@ -803,7 +837,64 @@ export const changeRiderAPI = async (selectedRider, selectedRow) => // 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. -export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.consignmentid ?? orderData.deliveryid, orderData); +// +// ---- 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. +const DELIVERY_STATUS_TO_BOOKING_STATUS = { + pending: 'Pending_Pickup', + accepted: 'Pickup_Scheduled', + picked: 'Converted_To_Consignment', + // The dialog offers "started", which this API has no separate state for — a + // consignment that has started IS out for delivery. + started: 'Out_for_Delivery', + active: 'Out_for_Delivery', + delivered: 'Delivered', + cancelled: 'Cancelled', + canceled: 'Cancelled' +}; + +export const updateDeliveryAPI = async (orderData) => { + const id = orderData.consignmentid ?? orderData.deliveryid; + const chosen = String(orderData.orderstatus || '').toLowerCase(); + const status = DELIVERY_STATUS_TO_BOOKING_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) { + return { + success: false, + message: chosen + ? `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.` + : 'Choose a status first.' + }; + } + + // 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 }); +}; // ==============================|| getalltenants (tenants) ||============================== // diff --git a/src/pages/nearle/assistant/CLAUDE.md b/src/pages/nearle/assistant/CLAUDE.md index 96844d0..2d7d32e 100644 --- a/src/pages/nearle/assistant/CLAUDE.md +++ b/src/pages/nearle/assistant/CLAUDE.md @@ -224,13 +224,27 @@ A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row arra Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated. -Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path. +### Assigning a rider — `assignActions.js` / `assignFlow.js` + +The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run. + +**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced. + +**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record. + +**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs. + +**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions. + +Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed. + +Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path. --- ## 4. What's deliberately out of scope right now -- **Deleting or reassigning anything.** Creates are built (§3.5); destructive and reassigning writes are not, and shouldn't be added without the same conversation. Cancelling an order or swapping a rider has downstream effects (FCM to the rider, the dispatch reconcile rule) that a confirm button doesn't cover. +- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite. - **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives. - **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built. - **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started. diff --git a/src/pages/nearle/assistant/DoormileAI/AIMessage.js b/src/pages/nearle/assistant/DoormileAI/AIMessage.js index 02a5aad..d24973f 100644 --- a/src/pages/nearle/assistant/DoormileAI/AIMessage.js +++ b/src/pages/nearle/assistant/DoormileAI/AIMessage.js @@ -135,7 +135,7 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction /> )} - {(message.form?.kind === 'confirmCustomer' || message.form?.kind === 'confirmOrder' || message.form?.kind === 'confirmBulk') && ( + {['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign', 'confirmBatchAssign'].includes(message.form?.kind) && ( {message.form.status === 'open' ? ( @@ -145,6 +145,10 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction ? `Create ${message.form.draft?.rows?.length || 0} orders` : message.form.kind === 'confirmOrder' ? 'Create order' + : message.form.kind === 'confirmAssign' + ? 'Assign rider' + : message.form.kind === 'confirmBatchAssign' + ? `Assign ${message.form.draft?.bookingIds?.length || 0} orders` : 'Submit' } variant="primary" diff --git a/src/pages/nearle/assistant/DoormileAI/AIPanel.js b/src/pages/nearle/assistant/DoormileAI/AIPanel.js index 62d5bb0..0046b40 100644 --- a/src/pages/nearle/assistant/DoormileAI/AIPanel.js +++ b/src/pages/nearle/assistant/DoormileAI/AIPanel.js @@ -21,6 +21,8 @@ import { executeCreateBulk } from '../bulkOrderActions'; import { startCustomerFlow, answerStep } from '../customerFlow'; import { startOrderFlow, answerOrderStep } from '../orderFlow'; import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow'; +import { startAssignFlow, answerAssignStep } from '../assignFlow'; +import { executeAssign, executeBatchAssign, describeRider } from '../assignActions'; import { getPageContext } from './pageContext'; import { Spark, LiveIndicator, TypingIndicator } from './AIParts'; import AIWelcome from './AIWelcome'; @@ -275,6 +277,47 @@ const AIPanel = ({ isOpen, onClose }) => { // Every field gathered — now the long pass. Locating is ~1 lookup/second, so // it is narrated into a single message the operator can watch and stop, // rather than freezing silently for minutes. + // The assign conversation's end. Two outcomes: the operator chose to keep the + // rider the backend already picked (nothing to send), or they picked someone + // and get the usual one-press gate. + const finishAssign = useCallback( + async (draft) => { + const label = draft.booking?.bookingno || `#${draft.booking?.bookingid}`; + + if (draft.__keep) { + push({ + sender: 'assistant', + text: `Left ${label} with ${describeRider(draft.__currentRider) || 'its current rider'}.`, + detail: 'Nothing was sent.' + }); + setFlow(null); + return; + } + + push({ + sender: 'assistant', + text: `Assign ${label} to ${describeRider(draft.__rider)}?`, + list: { + title: 'Will be sent', + numbered: false, + items: [ + { label: 'Order', meta: label }, + { label: 'Rider', meta: describeRider(draft.__rider) || '—' }, + draft.__currentRider ? { label: 'Replaces', meta: describeRider(draft.__currentRider) } : null, + { label: 'Drop', meta: draft.booking?.deliveryaddress || '—' } + ].filter(Boolean) + }, + // Stated because it is the difference between "the rider knows" and + // "the rider will find out when they next open the app". + detail: draft.__rider?.milerprofileid + ? 'The rider gets a push notification when this lands.' + : 'This rider has no profile id, so no notification can be sent.', + form: { kind: 'confirmAssign', status: 'open', draft } + }); + }, + [push] + ); + const finishBulk = useCallback( async (draft) => { const rows = draft.rows; @@ -381,6 +424,14 @@ const AIPanel = ({ isOpen, onClose }) => { // An order flow answers through this same path when the step is // free text; `select` steps come in through chooseStep instead. + if (flow.kind === 'assignRider') { + const next = await answerAssignStep(flow, question); + setFlow(next.flow); + await renderFlowStep(next, finishAssign); + setIsSending(false); + return; + } + if (flow.kind === 'createBulk') { const next = await answerBulkStep(flow, question); setFlow(next.flow); @@ -427,6 +478,14 @@ const AIPanel = ({ isOpen, onClose }) => { // The form version was replaced on explicit request; asking one // question at a time reads better and matches how an operator would // dictate a record. + if (result.form?.kind === 'assignRider') { + const started = await startAssignFlow(result.form.booking); + setFlow(started.flow); + await renderFlowStep(started, finishAssign); + setIsSending(false); + return; + } + if (result.form?.kind === 'createBulkOrders') { const started = await startBulkFlow(); setFlow(started.flow); @@ -508,7 +567,7 @@ const AIPanel = ({ isOpen, onClose }) => { setIsSending(false); } }, - [context, isSending, flow, renderFlowStep, finishOrder, finishBulk, push] + [context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, push] ); const copyMessage = useCallback((m) => { @@ -539,6 +598,64 @@ const AIPanel = ({ isOpen, onClose }) => { if (message.form?.status !== 'open') return; setFormStatus(message.id, { status: 'running', error: undefined, initial: values }); + // ---- assign one order to one rider ----------------------------------- + if (message.form.kind === 'confirmAssign') { + const { booking, __rider: rider } = message.form.draft; + const res = await executeAssign(booking, rider); + if (res.ok) { + setFormStatus(message.id, { status: 'done' }); + setFlow(null); + push({ + sender: 'assistant', + text: `${res.bookingLabel} is now with ${describeRider(rider)}.`, + detail: res.notified + ? 'The rider has been notified.' + : 'The assignment went through, but this rider has no profile id so no notification was sent.', + sourceCalls: res.sourceCalls + }); + } else { + setFormStatus(message.id, { status: 'failed', error: res.message }); + push({ + sender: 'assistant', + state: { tone: STATUS.error, iconKey: 'warning', title: 'Not assigned', body: res.message }, + sourceCalls: res.sourceCalls + }); + } + return; + } + + // ---- assign a whole run ---------------------------------------------- + // + // batch-assign, never N single assigns: it is the only call that sequences + // each rider's stops afterwards (doormile-flow.md §4). + if (message.form.kind === 'confirmBatchAssign') { + const ids = message.form.draft.bookingIds; + const res = await executeBatchAssign(ids); + if (res.ok) { + setFormStatus(message.id, { status: 'done' }); + push({ + sender: 'assistant', + text: `Assigned ${res.assigned} of ${ids.length} order${ids.length === 1 ? '' : 's'}.`, + detail: [ + res.sequenced ? `${res.sequenced} rider route${res.sequenced === 1 ? '' : 's'} sequenced by the optimiser.` : null, + res.notified ? `${res.notified} rider${res.notified === 1 ? '' : 's'} notified.` : null, + res.skipped ? `${res.skipped} could not be assigned — no rider in range, most likely.` : null + ] + .filter(Boolean) + .join(' '), + sourceCalls: res.sourceCalls + }); + } else { + setFormStatus(message.id, { status: 'failed', error: res.message }); + push({ + sender: 'assistant', + state: { tone: STATUS.error, iconKey: 'warning', title: 'Nothing was assigned', body: res.message }, + sourceCalls: res.sourceCalls + }); + } + return; + } + // ---- conversational order: submit what the chat gathered ------------- if (message.form.kind === 'confirmOrder') { const draft = message.form.draft; @@ -553,6 +670,18 @@ const AIPanel = ({ isOpen, onClose }) => { detail: 'It appears on the Orders page under Pending until a rider is assigned.', sourceCalls: res.sourceCalls }); + + // Straight into the assign conversation. It re-reads the booking first, + // so if the backend's own auto-assignment (doormile-flow.md §3) has + // already landed a rider, the operator is told who and asked before + // replacing them rather than silently overwriting a proximity-scored + // choice. + const booking = { bookingid: res.id, bookingno: res.bookingno, ...(res.created || {}) }; + if (booking.bookingid) { + const started = await startAssignFlow(booking); + setFlow(started.flow); + await renderFlowStep(started, finishAssign); + } } else { setFormStatus(message.id, { status: 'failed', error: res.message }); push({ @@ -627,6 +756,18 @@ const AIPanel = ({ isOpen, onClose }) => { downloadRows: [...res.failures.map((f) => ({ ...rows[f.index], error: f.reason })), ...(invalid || [])], sourceCalls: res.sourceCalls }); + + // Only offered when the endpoint actually returned the ids. No ids means + // "cannot offer assignment" — never "nothing was created". + if (res.createdIds?.length) { + push({ + sender: 'assistant', + text: `Assign ${res.createdIds.length} of them now?`, + detail: + 'Riders are picked by the backend and each one’s stops are sequenced by the route optimiser — that only happens through the batch endpoint, not by assigning one at a time.', + form: { kind: 'confirmBatchAssign', status: 'open', draft: { bookingIds: res.createdIds } } + }); + } setFlow(null); return; } @@ -715,10 +856,11 @@ const AIPanel = ({ isOpen, onClose }) => { setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m))); setIsSending(true); try { - const isBulk = flow.kind === 'createBulk'; - const next = isBulk ? await answerBulkStep(flow, value, option) : await answerOrderStep(flow, value, option); + const answer = flow.kind === 'createBulk' ? answerBulkStep : flow.kind === 'assignRider' ? answerAssignStep : answerOrderStep; + const finish = flow.kind === 'createBulk' ? finishBulk : flow.kind === 'assignRider' ? finishAssign : finishOrder; + const next = await answer(flow, value, option); setFlow(next.flow); - await renderFlowStep(next, isBulk ? finishBulk : finishOrder); + await renderFlowStep(next, finish); } catch (err) { console.error('[Doormile AI] flow step failed', err); push({ sender: 'assistant', text: 'Something went wrong on that step — say “cancel” and start again.' }); @@ -726,7 +868,7 @@ const AIPanel = ({ isOpen, onClose }) => { setIsSending(false); } }, - [flow, push, renderFlowStep, finishBulk, finishOrder] + [flow, push, renderFlowStep, finishBulk, finishOrder, finishAssign] ); // Stops the long locate/price pass. The flag is a ref for the reason diff --git a/src/pages/nearle/assistant/assignActions.js b/src/pages/nearle/assistant/assignActions.js new file mode 100644 index 0000000..806833c --- /dev/null +++ b/src/pages/nearle/assistant/assignActions.js @@ -0,0 +1,214 @@ +import { getMilers, assignMilerToBooking, batchAssignBookings } from 'pages/api/doormileApi'; +import { buildMilerLookup, notifyRider } from 'pages/api/api'; + +// ==============================|| Doormile AI — assigning a rider ||============================== // +// +// Fourth write capability. Two endpoints, and which one is correct depends +// entirely on how many orders are being assigned: +// +// ONE order → POST /admin/bookings/:id/assign-miler +// MANY orders → POST /hub/bookings/batch-assign +// +// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only +// place stops get ordered" — after assigning it sends each affected rider's +// whole active set to the route optimiser and writes step, per-leg distance and +// ETA back onto `bookingassignments`. Assigning ten orders with ten single +// calls leaves every route unsequenced and riders choosing their own order. +// +// ---- The two rider IDs ----------------------------------------------------- +// +// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify` +// keys off a **milerprofileid**. Different identity spaces on adjacent +// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails +// quietly in both directions: the assign 404s, or the rider is never told. +// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already +// uses for exactly this translation. Don't grow a second lookup here. +// +// ---- Assignment is normally automatic -------------------------------------- +// +// Creating a booking publishes `booking.assignment_requested`; a worker finds a +// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times, +// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a +// decision the backend is already making, which is why the flow re-reads the +// booking's current assignee before offering to change it. + +export const ASSIGN_TRIGGER = + /\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i; + +// Riders, plus the id bridge, in one call. +export const loadRiders = async () => { + const milers = (await getMilers()) || []; + return { milers, lookup: buildMilerLookup(milers) }; +}; + +const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`; + +// Available riders first — an operator picking by hand wants the ones who can +// actually take it at the top. Beyond that, alphabetical: any other ordering +// (nearest, least loaded) would need position data this list doesn't carry, and +// a proximity label nobody can stand behind is worse than none. +const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || '')); + +export const riderOptions = (milers) => + [...(milers || [])] + .sort((a, b) => { + const byAvail = Number(isAvailable(b)) - Number(isAvailable(a)); + return byAvail || riderName(a).localeCompare(riderName(b)); + }) + .map((m) => ({ + value: String(m.userid), + label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '), + record: m + })); + +// Who currently holds this booking, resolved to a person rather than an id. +export const currentAssignee = (booking, lookup) => + booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null; + +export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null); + +// ---- one order -------------------------------------------------------------- +export const executeAssign = async (booking, rider) => { + const started = Date.now(); + const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`; + const call = { + name: 'assignMilerToBooking', + target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`, + stats: `${bookingLabel} → ${riderName(rider)}` + }; + + try { + // mileruserid, NOT milerprofileid. See the note at the top of this file. + const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) }); + const duration = `${Date.now() - started}ms`; + + if (res && res.success === false) { + return { + ok: false, + message: res.message || 'The server refused the assignment.', + sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }] + }; + } + + const sourceCalls = [{ ...call, duration, status: 'complete' }]; + + // CLAUDE.md §9: any mutation that affects a rider is followed by a push. + // It is deliberately NOT allowed to fail the assignment — the order IS + // assigned at this point, and reporting otherwise would be a lie. + if (rider.milerprofileid) { + try { + await notifyRider(rider.milerprofileid); + sourceCalls.push({ + name: 'notifyRider', + target: `POST /admin/milers/${rider.milerprofileid}/notify`, + status: 'complete', + stats: 'rider notified' + }); + } catch (err) { + sourceCalls.push({ + name: 'notifyRider', + target: `POST /admin/milers/${rider.milerprofileid}/notify`, + status: 'error', + errorMessage: err.message || 'notification failed' + }); + } + } else { + // No profile id means no push is possible — say so rather than letting + // the operator assume the rider's phone buzzed. + sourceCalls.push({ + name: 'notifyRider', + target: '/admin/milers/:id/notify', + status: 'error', + errorMessage: 'this rider has no milerprofileid, so no notification could be sent' + }); + } + + return { ok: true, rider, bookingLabel, notified: Boolean(rider.milerprofileid), sourceCalls }; + } catch (err) { + // doormileAxios rejects with the response BODY; the status rides on + // err.httpStatus. + const status = err.httpStatus; + const message = status + ? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${ + err.message ? ` — ${err.message}` : '' + }. Nothing changed.` + : `${err.message || 'The request failed'} — nothing changed.`; + return { + ok: false, + message, + sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }] + }; + } +}; + +// ---- many orders ------------------------------------------------------------ +// +// The backend picks the riders here, not the operator: batch-assign spreads the +// set across riders within range and then sequences each one's stops. Its reply +// is per-booking, so a partial result is reported per row rather than as a bare +// success. +export const executeBatchAssign = async (bookingIds, maxPerRider = 5) => { + const started = Date.now(); + const call = { + name: 'batchAssignBookings', + target: 'POST /hub/bookings/batch-assign', + stats: `${bookingIds.length} order${bookingIds.length === 1 ? '' : 's'}` + }; + + try { + const res = await batchAssignBookings(bookingIds, maxPerRider); + const duration = `${Date.now() - started}ms`; + const results = Array.isArray(res?.results) ? res.results : null; + const assigned = Number.isFinite(res?.assigned) ? res.assigned : results?.filter((r) => r.assigned).length; + + const sourceCalls = [ + { + ...call, + duration, + status: 'complete', + stats: `${assigned ?? '?'} assigned, ${res?.riderssequenced ?? 0} rider route${res?.riderssequenced === 1 ? '' : 's'} sequenced` + } + ]; + + // One push per rider, not per order — ten orders to one rider is one buzz. + const { lookup } = await loadRiders().catch(() => ({ lookup: null })); + const notified = new Set(); + if (results && lookup) { + // eslint-disable-next-line no-restricted-syntax + for (const r of results) { + const rider = r.mileruserid ? lookup.byUserId.get(String(r.mileruserid)) : null; + if (rider?.milerprofileid && !notified.has(rider.milerprofileid)) { + notified.add(rider.milerprofileid); + // eslint-disable-next-line no-await-in-loop + await notifyRider(rider.milerprofileid).catch(() => {}); + } + } + sourceCalls.push({ + name: 'notifyRider', + target: '/admin/milers/:id/notify', + status: 'complete', + stats: `${notified.size} rider${notified.size === 1 ? '' : 's'} notified` + }); + } + + return { + ok: (assigned ?? 0) > 0, + assigned: assigned ?? 0, + skipped: res?.skipped ?? Math.max(0, bookingIds.length - (assigned ?? 0)), + sequenced: res?.riderssequenced ?? 0, + results, + notified: notified.size, + sourceCalls + }; + } catch (err) { + const status = err.httpStatus; + const message = status + ? `POST /hub/bookings/batch-assign returned ${status}${err.message ? ` — ${err.message}` : ''}. Nothing was assigned.` + : `${err.message || 'The request failed'} — nothing was assigned.`; + return { + ok: false, + message, + sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }] + }; + } +}; diff --git a/src/pages/nearle/assistant/assignFlow.js b/src/pages/nearle/assistant/assignFlow.js new file mode 100644 index 0000000..ab1bf0c --- /dev/null +++ b/src/pages/nearle/assistant/assignFlow.js @@ -0,0 +1,111 @@ +import { scanBookings } from './intents'; +import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions'; +import { advanceFlow, startFlow, answerFlowStep } from './flowEngine'; + +// ==============================|| Doormile AI — conversational assign ||============================== // +// +// Reached two ways, and the difference is only what the draft is seeded with: +// +// • straight after creating an order — the panel seeds `booking`, so the +// first question is already about a known order +// • "assign a rider to DM-BK-…" — the operator names it, and the first step +// resolves that reference against the real booking list +// +// ---- Why there is a "keep or replace" step --------------------------------- +// +// The backend assigns riders BY ITSELF within seconds of creation and keeps +// retrying for ten minutes (doormile-flow.md §3). By the time an operator +// answers a dropdown, a rider may already hold the order — one the backend +// chose on proximity, which is information this list does not have. +// +// So the flow re-reads the booking's current assignee and, if there is one, +// asks before replacing them. Silently overwriting would throw away a better +// decision and strand a rider who has already been told the job is theirs. + +export const ASSIGN_STEPS = [ + { + id: 'bookingno', + type: 'text', + ask: 'Which order? Give me its number — for example DM-BK-0D915D43-33705.', + // Seeded by the panel when this follows a create, so it is skipped there. + when: (d) => !d.booking, + resolve: async (raw) => { + const needle = String(raw || '') + .trim() + .toLowerCase(); + if (!needle) return { error: 'I need an order number.' }; + + const scan = await scanBookings(); + const found = scan.rows.find( + (b) => + String(b.bookingno || '').toLowerCase() === needle || + String(b.bookingid) === needle || + String(b.bookingno || '') + .toLowerCase() + .includes(needle) + ); + if (!found) { + return { + error: scan.truncated + ? `I couldn't find ${raw} in the most recent ${scan.scanned.toLocaleString( + 'en-IN' + )} bookings. It may be further back than I can scan.` + : `I couldn't find an order matching ${raw}. Check the number and try again.` + }; + } + // Resolve the current holder HERE, not afterwards. advanceFlow evaluates + // keepOrReplace's `when` the instant this step is applied — a lookup that + // lands even one tick later means the step is skipped and an + // already-assigned order is silently reassigned. + const { lookup } = await loadRiders().catch(() => ({ lookup: null })); + return { value: { booking: found, currentRider: lookup ? currentAssignee(found, lookup) : null } }; + }, + apply: (d, v) => ({ ...d, booking: v.booking, __currentRider: v.currentRider }) + }, + { + id: 'keepOrReplace', + type: 'select', + // The question text is rewritten by the panel to name the current holder; + // this is the fallback if that lookup came back empty. + ask: 'This order already has a rider. Keep them, or assign someone else?', + when: (d) => Boolean(d.__currentRider), + options: async (d) => [ + { value: 'keep', label: `Keep ${describeRider(d.__currentRider) || 'the current rider'}` }, + { value: 'replace', label: 'Assign someone else' } + ], + apply: (d, v) => ({ ...d, keepOrReplace: v, __keep: v === 'keep' }) + }, + { + id: 'mileruserid', + type: 'select', + ask: 'Which rider should take it?', + when: (d) => !d.__keep, + options: async () => { + const { milers } = await loadRiders(); + return riderOptions(milers); + }, + // The whole miler record travels with the answer: the assign call needs its + // `userid` and the notification needs its `milerprofileid`, and those are + // different fields on the same record. + apply: (d, v, option) => ({ ...d, mileruserid: v, __rider: option?.record }) + } +]; + +// `booking` is optional — present when this follows a create. +export const startAssignFlow = async (booking) => { + const draft = booking ? { booking } : {}; + if (booking) { + // Resolve who holds it right now, so the keep/replace step knows whether to + // ask at all. A failure here degrades to "nobody assigned yet", which is + // the safe direction: the operator is asked to choose rather than being + // told something untrue about the current rider. + const { lookup } = await loadRiders().catch(() => ({ lookup: null })); + const held = lookup ? currentAssignee(booking, lookup) : null; + if (held) draft.__currentRider = held; + } + return startFlow(ASSIGN_STEPS, 'assignRider', draft); +}; + +export const advanceAssign = (flow) => advanceFlow(ASSIGN_STEPS, flow); + +export const answerAssignStep = (flow, raw, option) => answerFlowStep(ASSIGN_STEPS, flow, raw, option); diff --git a/src/pages/nearle/assistant/bulkOrderActions.js b/src/pages/nearle/assistant/bulkOrderActions.js index 5612cea..3e4cede 100644 --- a/src/pages/nearle/assistant/bulkOrderActions.js +++ b/src/pages/nearle/assistant/bulkOrderActions.js @@ -196,6 +196,9 @@ export const executeCreateBulk = async (rows, shared) => { const sourceCalls = []; let created = 0; const failures = []; + // The ids of what actually landed, so the run can be handed straight to + // batch-assign without a re-scan of /admin/bookings to find them again. + const createdIds = []; for (let b = 0; b < batches.length; b += 1) { const batch = batches[b]; @@ -223,7 +226,10 @@ export const executeCreateBulk = async (rows, shared) => { if (perRow) { perRow.forEach((r, i) => { if (r?.success === false || r?.error) failures.push({ index: b * BULK_MAX + i, reason: r.message || r.error || 'Rejected' }); - else created += 1; + else { + created += 1; + if (r?.bookingid) createdIds.push(r.bookingid); + } }); } else { created += batch.length; @@ -253,6 +259,9 @@ export const executeCreateBulk = async (rows, shared) => { return { ok: created > 0, created, + // Empty when the endpoint returned no per-row array — the caller must treat + // "no ids" as "cannot offer assignment", not as "nothing was created". + createdIds, failed: failures.length, failures, batches: batches.length, diff --git a/src/pages/nearle/assistant/intents.js b/src/pages/nearle/assistant/intents.js index 629915c..fdd167e 100644 --- a/src/pages/nearle/assistant/intents.js +++ b/src/pages/nearle/assistant/intents.js @@ -30,6 +30,7 @@ import { STATUS } from 'themes/dt/tokens'; // intent must have no route to a write. import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions'; import { CREATE_ORDER_TRIGGER } from './orderActions'; +import { ASSIGN_TRIGGER } from './assignActions'; import { CREATE_BULK_TRIGGER } from './bulkOrderActions'; import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter'; import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups'; @@ -412,7 +413,10 @@ const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day); // everywhere rather than inside a range. The sentinel bounds keep one code // path: they can never trigger the early-stop, so this always drains to the // page budget and reports `truncated` honestly if the id could be further back. -const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31'); +// Exported for assignFlow.js, which resolves a typed order number against the +// real booking list. It reuses this rather than growing a second scanner with +// its own idea of pagination and truncation. +export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31'); // A count built on a truncated scan is a floor, not a total — say "at least". const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`; @@ -806,6 +810,35 @@ const INTENTS = [ }; } }, + { + // Ordered ahead of orderLookup: "assign a rider to DM-BK-…" names an order + // number, and orderLookup would otherwise claim it and answer with the + // order's details instead of assigning anything. + id: 'assignRider', + label: 'Assign a rider to an order — e.g. "assign a rider to DM-BK-0D915D43-33705"', + match: (text) => (ASSIGN_TRIGGER.test(text) ? { ref: orderIdFromWords(text) } : null), + run: async ({ ref }) => { + // No order named — the flow will ask for one. + if (!ref?.strong) { + return { headline: 'Which order should I assign?', form: { kind: 'assignRider', status: 'open' }, sourceCalls: [] }; + } + const scan = await scanBookings(); + const needle = String(ref.id).toLowerCase(); + const found = scan.rows.find((b) => String(b.bookingno || '').toLowerCase() === needle || String(b.bookingid) === ref.id); + if (!found) { + return { + headline: `I couldn't find order ${ref.id}.`, + detail: truncationNote(scan).trim() || 'Check the order number and try again.', + sourceCalls: [scanCall(scan, `no match for "${ref.id}"`)] + }; + } + return { + headline: `Assigning ${bookingLabel(found)}.`, + form: { kind: 'assignRider', status: 'open', booking: found }, + sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`)] + }; + } + }, { id: 'orderLookup', label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"', diff --git a/src/pages/nearle/deliveries/deliveries.js b/src/pages/nearle/deliveries/deliveries.js index 0b3ec86..062f181 100644 --- a/src/pages/nearle/deliveries/deliveries.js +++ b/src/pages/nearle/deliveries/deliveries.js @@ -583,14 +583,7 @@ const Deliveries = () => { isLoading: countSourceIsLoading, refetch: countSourceRefetch } = useInfiniteQuery({ - // The trailing 'activity' is the date basis (api.js reads queryKey[11]): - // keep a row whose LAST UPDATE falls in the window, not only one created in - // it. Scoping a delivery board by creation date hid every order booked - // yesterday and picked or delivered today — which is why each tab past - // Accepted read 0 while the day's fresh orders filled Pending and Accepted. - // The other three callers of fetchDeliveries deliberately keep the old - // created-only basis. - queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid, 'activity'], + queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid], queryFn: fetchDeliveries, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined }); diff --git a/src/pages/nearle/dispatch/CLAUDE.md b/src/pages/nearle/dispatch/CLAUDE.md index 5807eaf..1a0661a 100644 --- a/src/pages/nearle/dispatch/CLAUDE.md +++ b/src/pages/nearle/dispatch/CLAUDE.md @@ -48,6 +48,12 @@ This is a constant, not state. The operator-facing time-field dropdown and the s Those two key positions were previously destructured-but-ignored, which meant changing the date refetched and returned an identical row set — every batch count was the same for today, yesterday and last week. If you add a filter to this query, make sure `fetchDeliveries` actually reads it; a query key position that nothing consumes is a silent no-op. +#### ⛔ Don't admit rows on `updatedat` while bucketing on `createdat` + +An opt-in "activity" date basis was added so an order booked yesterday and still moving today would appear on today's board, then **reverted**. Admitting a row on one timestamp while bucketing it on another cannot produce an honest batch: an order created yesterday at 6 PM and merely touched today was let onto today's board and then bucketed by its creation hour, so **Evening Batch read 6 at 10:41 in the morning on a day with no orders created at all**. + +The filter field and the bucket field have to be the same field. If carried-over work needs to be visible on the board, it needs its own bucket ("Carried over") — not a time-of-day wave it does not belong to. Decide that before reaching for the date filter again. + ### ⛔ Never bucket on `assigntime` It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it. diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 7f646a2..ea6bb8f 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -1330,18 +1330,7 @@ const Dispatch = ({ hasNextPage: liveHasNextPage, isFetchingNextPage: liveIsFetchingNextPage } = useInfiniteQuery({ - // The trailing 'activity' is the date basis (api.js reads queryKey[11]). - // Without it this board was scoped to orders CREATED on the selected date, - // so an order booked yesterday and accepted, picked or still in transit - // today never appeared — the one case a live dispatch board most needs to - // show. A row now qualifies if it was created OR last updated in the - // window. - // - // Batch bucketing is unchanged: it still reads `orderdate` (BATCH_TIME_FIELD - // = 'created'), so a carried-over order buckets by the time of day it was - // originally placed. That is deliberate — the wave an order belongs to - // doesn't change because it is late. - queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0, 'activity'], + queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0], queryFn: fetchDeliveries, getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, enabled: shouldFetchLive, diff --git a/src/pages/nearle/dispatch/ProfitabilitySection.js b/src/pages/nearle/dispatch/ProfitabilitySection.js index 92449f1..4483b1f 100644 --- a/src/pages/nearle/dispatch/ProfitabilitySection.js +++ b/src/pages/nearle/dispatch/ProfitabilitySection.js @@ -15,7 +15,7 @@ import { } from 'react-icons/md'; import { Grid } from '@astryxdesign/core/Grid'; -import { KpiGrid } from 'components/nearle_components/PageLayout'; +import { KpiGrid, TABLE_FRAME } from 'components/nearle_components/PageLayout'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Center } from '@astryxdesign/core/Center'; @@ -375,8 +375,15 @@ function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggl if (!isFocused && onFocus) onFocus(rider); }; + // The resting frame matches every other table in the app. The focused state + // keeps its accent outline — that is a selection affordance, not decoration, + // and the brief was explicit about leaving selection alone. return ( - + { - + @@ -658,17 +658,24 @@ const Createorder1 = () => { + {/* One fixed-width, consistently-labelled slot for whichever + picker state is active, so the control never jumps width or + loses vertical alignment with the title block as it switches + between loading / error / badge / dropdown. */} {locationsStatus === 'loading' ? ( - - Loading business locations… - +
+ + Loading business locations… + +
) : 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. -
+ + Tenant { isLoading={staffTenantListLoading} hasSearch /> -
+
) : locationsStatus === 'no-tenant' ? ( - - This login has no tenant assigned — sign in as a tenant user - +
+ + This login has no tenant assigned — sign in as a tenant user + +
) : locationsStatus === 'error' ? ( - - Could not load business locations — tap to retry - +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + gettenantlocations(); + } + }} + > + + Could not load business locations — tap to retry + +
) : locationsStatus === 'empty' ? ( - - No saved locations — enter pickup details manually below - +
+ + No saved locations — enter pickup details manually below + +
) : tenantLocations.length === 1 ? ( - } label={locationDisplayName(tenantLocations[0])} /> + + Business Location + } label={locationDisplayName(tenantLocations[0])} /> + ) : ( -
+ + Business Location { hasSearch hasClear /> -
+ )} - {/* Stage indicator. Derived from the form's own state rather than a - wizard step counter — the page is one scrollable form, so this - reports progress instead of gating it. */} - - {[ - { n: 1, label: 'Pickup & Drop', done: Boolean(pickCust.address && dropCust.address) }, - { n: 2, label: 'Schedule', done: Boolean(selectedtime) }, - { n: 3, label: 'Package', done: Boolean(category) }, - { n: 4, label: 'Review', done: showDistance && totalCharge > 0 } - ].map((step, i, all) => { - const active = !step.done && all.slice(0, i).every((prev) => prev.done); - return ( - - {i > 0 && } - - {step.done ? '' : step.n} - {step.label} - - - ); - })} - - - {/* The delivery journey, stated once at the top so the order's shape - is obvious before reading any field. */} - - - {pickCust.address || 'Pickup address'} - - {showDistance && ( - - {distance} km{durationMin ? ` · ${durationMin} min` : ''} - - )} - - {dropCust.address || 'Drop address'} - - - {/* ================================================= || Pickup || ================================================= */} @@ -1282,9 +1271,14 @@ const Createorder1 = () => { {/* ============================== || Order summary || ============================== */} - {/* Sticky on desktop, static below 1024px (see CreateOrder.css). Reads - straight off form state so it updates as the operator types — there - is no second source of truth to drift from. */} + {/* A normal full-width card in the single-column form flow (not a + sidebar next to a taller column), so it stays static. It used to + carry `position: sticky`, inherited from a two-column layout this + page never actually has — with only one column, the card is the + last block on the page, so sticking it caused a visible jump/ + collapse right as its own container's bottom edge scrolled into + view. Reads straight off form state so it updates as the operator + types — there is no second source of truth to drift from. */} } title="Order Summary" subtitle="Updates as you fill the form" /> diff --git a/src/pages/nearle/orders/multipleOrders.js b/src/pages/nearle/orders/multipleOrders.js index 7a3833e..76d1dd5 100644 --- a/src/pages/nearle/orders/multipleOrders.js +++ b/src/pages/nearle/orders/multipleOrders.js @@ -49,7 +49,7 @@ import { FaLocationDot } from 'react-icons/fa6'; import Loader from 'components/Loader'; import PageHeader from 'components/nearle_components/PageHeader'; -import { PageShell } from 'components/nearle_components/PageLayout'; +import { PageShell, TABLE_FRAME } from 'components/nearle_components/PageLayout'; import { DT } from 'themes/dt/tokens'; import { TableScroll } from 'themes/dt/primitives'; import './OrdersRedesign.css'; @@ -1250,7 +1250,10 @@ const MultipleOrders = () => { {/* RIGHT — file / drop preview */} - + {/* Same frame as every DataCard table — this preview holds a table but + predates DataCard, so it states the shared constant rather than + carrying a second definition of it. */} + {previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'} diff --git a/src/themes/dt/primitives.js b/src/themes/dt/primitives.js index 40b34fd..b666060 100644 --- a/src/themes/dt/primitives.js +++ b/src/themes/dt/primitives.js @@ -50,6 +50,11 @@ export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush border: 2px solid transparent; background-clip: padding-box; } .dt-table-scroll::-webkit-scrollbar-thumb:hover { background-color: rgba(15, 23, 42, 0.34); } + /* Internal separators, pinned to the same neutral as the card frame so a + row line can never read heavier than the table's own outline. Astryx's + Table draws its own dividers; this only restates their COLOUR, scoped + to our wrapper, so no layout, density or divider MODE is touched. */ + .dt-table-scroll table td, .dt-table-scroll table th { border-color: ${DT.borderSubtle}; } .dt-table-scroll thead th { position: sticky; top: 0; z-index: 2; } .dt-table-scroll:not(.is-flush) thead th { background-color: ${DT.surfaceAlt}; } .dt-table-scroll.is-flush thead th {