updates on the ai bot and assigned by chatbot

This commit is contained in:
2026-08-20 12:00:12 +05:30
parent 37ca2352e2
commit e1cc874218
17 changed files with 794 additions and 248 deletions

View File

@@ -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 (
<Card
padding={0}
elevation="none"
style={fill ? { overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : { overflow: 'hidden' }}
style={
fill
? { border: TABLE_FRAME, overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }
: { border: TABLE_FRAME, overflow: 'hidden' }
}
>
{toolbar}
{toolbar && <Divider />}

View File

@@ -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) ||============================== //

View File

@@ -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.

View File

@@ -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) && (
<VStack className="dai-action" gap={1.5} padding={0}>
{message.form.status === 'open' ? (
<HStack gap={1.5} padding={0}>
@@ -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"

View File

@@ -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

View File

@@ -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 }]
};
}
};

View File

@@ -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);

View File

@@ -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,

View File

@@ -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"',

View File

@@ -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
});

View File

@@ -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.

View File

@@ -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,

View File

@@ -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 (
<Card padding={0} elevation="none" style={isFocused ? { outline: '2px solid var(--color-accent)' } : undefined}>
<Card
padding={0}
elevation="none"
style={{ border: TABLE_FRAME, ...(isFocused ? { outline: '2px solid var(--color-accent)' } : null) }}
>
<Collapsible
isOpen={isExpanded}
onOpenChange={handleOpenChange}

View File

@@ -59,122 +59,39 @@
}
/* --------------------------------------------------------------------------
Stage indicator
--------------------------------------------------------------------------
The layout rules below are NOT scoped under .co-root. Only theming needs
the scope; structure must survive it. When `co-root` was being dropped by
PageShell (it didn't forward className), every scoped rule failed silently
and the chips rendered as run-together plain text — "1Pickup & Drop2Schedule".
Keeping layout unscoped means the worst case is unthemed, not unreadable.
Header picker — tenant / business-location control that sits opposite the
title block. One fixed-width slot shared by every state (loading, error,
badge, dropdown) so the control never changes size or drifts out of
vertical alignment with the title as `locationsStatus` changes.
-------------------------------------------------------------------------- */
.co-steps {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
.co-header-picker {
min-width: 280px;
max-width: 320px;
flex: 0 1 auto;
/* Pins the picker to the far right of the header row even when the row
wraps — a single child on a wrapped flex line sits at flex-start under
justify-content: space-between, so margin-left: auto is what actually
keeps it flush right in that case. */
margin-left: auto;
}
.co-step {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 11px;
border: 1px solid var(--co-border);
border-radius: 999px;
background: var(--co-surface);
font-size: 12.5px;
font-weight: 500;
color: var(--co-text-3);
white-space: nowrap;
}
.co-root .co-step[data-state='active'] {
color: var(--co-brand);
border-color: color-mix(in srgb, var(--co-brand) 35%, transparent);
background: color-mix(in srgb, var(--co-brand) 6%, #fff);
}
.co-root .co-step[data-state='done'] {
color: var(--co-pickup);
border-color: color-mix(in srgb, var(--co-pickup) 30%, transparent);
background: color-mix(in srgb, var(--co-pickup) 6%, #fff);
}
.co-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 999px;
font-size: 10.5px;
.co-root .co-header-picker-label {
font-size: 11px;
font-weight: 600;
background: currentColor;
color: #fff;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--co-text-3);
}
.co-root .co-step[data-state='done'] .co-step-num::after {
content: '✓';
}
.co-step-sep {
flex: 0 0 auto;
width: 14px;
height: 1px;
background: var(--co-border);
}
/* --------------------------------------------------------------------------
Journey — Pickup ●───→● Drop
-------------------------------------------------------------------------- */
.co-journey {
.co-root .co-header-picker-status {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 13px;
margin-bottom: 14px;
min-height: 38px;
padding: 8px 12px;
border: 1px solid var(--co-border);
border-radius: var(--co-radius);
background: var(--co-surface);
font-size: 12.5px;
color: var(--co-text-2);
overflow: hidden;
}
.co-journey-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
border-radius: 999px;
}
.co-root .co-journey-dot[data-end='pickup'] {
background: var(--co-pickup);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-pickup) 18%, transparent);
}
.co-root .co-journey-dot[data-end='drop'] {
background: var(--co-drop);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-drop) 18%, transparent);
}
.co-journey-line {
flex: 1 1 auto;
min-width: 24px;
height: 1px;
background: linear-gradient(90deg, var(--co-pickup), var(--co-drop));
opacity: 0.5;
}
.co-journey-label {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--co-text);
box-sizing: border-box;
}
/* --------------------------------------------------------------------------
@@ -251,12 +168,23 @@
color: var(--co-text-3);
}
/* --------------------------------------------------------------------------
Sticky order summary (desktop only)
-------------------------------------------------------------------------- */
.co-root .co-summary {
position: sticky;
top: calc(var(--appshell-header-height, 64px) + 12px);
/* Pickup/drop dot — shared by the order-summary legs below. */
.co-journey-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
margin-top: 4px;
border-radius: 999px;
}
.co-root .co-journey-dot[data-end='pickup'] {
background: var(--co-pickup);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-pickup) 18%, transparent);
}
.co-root .co-journey-dot[data-end='drop'] {
background: var(--co-drop);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-drop) 18%, transparent);
}
.co-root .co-summary-leg {
@@ -291,12 +219,6 @@
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 1023px) {
.co-root .co-summary {
position: static;
}
}
@media (max-width: 640px) {
.co-root .co-title {
font-size: 20px;
@@ -306,19 +228,9 @@
gap: 10px;
}
.co-root .co-steps {
gap: 4px;
}
.co-root .co-step {
padding: 5px 9px;
font-size: 11.5px;
}
/* The step separators are decoration; drop them before the chips wrap
awkwardly on a 360px screen. */
.co-root .co-step-sep {
display: none;
.co-header-picker {
min-width: 0;
width: 100%;
}
}

View File

@@ -647,7 +647,7 @@ const Createorder1 = () => {
<PageShell className="co-root">
<VStack className="co-header" gap={0} padding={0}>
<HStack justify="between" vAlign="start" gap={2} wrap="wrap" padding={0}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap" padding={0}>
<HStack gap={2} vAlign="center" padding={0}>
<AccentAvatar color={BRAND} selected size={40}>
<MdAddShoppingCart size={20} />
@@ -658,17 +658,24 @@ const Createorder1 = () => {
</VStack>
</HStack>
{/* 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' ? (
<Text color="secondary" weight="bold">
Loading business locations…
</Text>
<div className="co-header-picker co-header-picker-status">
<Text color="secondary" weight="bold">
Loading business locations…
</Text>
</div>
) : 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.
<div style={{ minWidth: 260 }}>
<VStack className="co-header-picker" gap={0.5} padding={0}>
<Text className="co-header-picker-label">Tenant</Text>
<Selector
label="Tenant"
isLabelHidden
@@ -679,23 +686,44 @@ const Createorder1 = () => {
isLoading={staffTenantListLoading}
hasSearch
/>
</div>
</VStack>
) : locationsStatus === 'no-tenant' ? (
<Text style={{ color: '#b91c1c' }} weight="bold">
This login has no tenant assigned — sign in as a tenant user
</Text>
<div className="co-header-picker co-header-picker-status">
<Text style={{ color: '#b91c1c' }} weight="bold">
This login has no tenant assigned — sign in as a tenant user
</Text>
</div>
) : locationsStatus === 'error' ? (
<Text style={{ color: '#b91c1c', cursor: 'pointer' }} weight="bold" onClick={gettenantlocations}>
Could not load business locations — tap to retry
</Text>
<div
className="co-header-picker co-header-picker-status"
role="button"
tabIndex={0}
onClick={gettenantlocations}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
gettenantlocations();
}
}}
>
<Text style={{ color: '#b91c1c', cursor: 'pointer' }} weight="bold">
Could not load business locations — tap to retry
</Text>
</div>
) : locationsStatus === 'empty' ? (
<Text style={{ color: '#b45309' }} weight="bold">
No saved locations — enter pickup details manually below
</Text>
<div className="co-header-picker co-header-picker-status">
<Text style={{ color: '#b45309' }} weight="bold">
No saved locations — enter pickup details manually below
</Text>
</div>
) : tenantLocations.length === 1 ? (
<Badge variant="neutral" icon={<MdMyLocation size={14} />} label={locationDisplayName(tenantLocations[0])} />
<VStack className="co-header-picker" gap={0.5} padding={0}>
<Text className="co-header-picker-label">Business Location</Text>
<Badge variant="neutral" icon={<MdMyLocation size={14} />} label={locationDisplayName(tenantLocations[0])} />
</VStack>
) : (
<div style={{ minWidth: 300 }}>
<VStack className="co-header-picker" gap={0.5} padding={0}>
<Text className="co-header-picker-label">Business Location</Text>
<Selector
label="Business Location"
isLabelHidden
@@ -731,50 +759,11 @@ const Createorder1 = () => {
hasSearch
hasClear
/>
</div>
</VStack>
)}
</HStack>
</VStack>
{/* 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. */}
<HStack className="co-steps" padding={0} gap={0}>
{[
{ 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 (
<HStack key={step.n} padding={0} gap={0} vAlign="center">
{i > 0 && <span className="co-step-sep" />}
<span className="co-step" data-state={step.done ? 'done' : active ? 'active' : 'todo'}>
<span className="co-step-num">{step.done ? '' : step.n}</span>
{step.label}
</span>
</HStack>
);
})}
</HStack>
{/* The delivery journey, stated once at the top so the order's shape
is obvious before reading any field. */}
<HStack className="co-journey" padding={0} gap={0} vAlign="center">
<span className="co-journey-dot" data-end="pickup" />
<span className="co-journey-label">{pickCust.address || 'Pickup address'}</span>
<span className="co-journey-line" />
{showDistance && (
<span className="co-price-note">
{distance} km{durationMin ? ` · ${durationMin} min` : ''}
</span>
)}
<span className="co-journey-line" />
<span className="co-journey-label">{dropCust.address || 'Drop address'}</span>
<span className="co-journey-dot" data-end="drop" />
</HStack>
<Grid columns={{ minWidth: 420, repeat: 'fit' }} gap={3}>
{/* ================================================= || Pickup || ================================================= */}
<Card padding={0} elevation="none" className="co-card" data-end="pickup">
@@ -1282,9 +1271,14 @@ const Createorder1 = () => {
</Card>
{/* ============================== || 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. */}
<Card padding={0} elevation="none" className="co-card co-summary">
<SectionHeader color="#0f172a" icon={<MdCheckCircle size={16} />} title="Order Summary" subtitle="Updates as you fill the form" />
<VStack gap={2} padding={3}>

View File

@@ -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 = () => {
</VStack>
{/* RIGHT — file / drop preview */}
<Card padding={2} elevation="none" style={{ display: 'flex', flexDirection: 'column' }}>
{/* 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. */}
<Card padding={2} elevation="none" style={{ border: TABLE_FRAME, display: 'flex', flexDirection: 'column' }}>
<HStack justify="between" vAlign="center" gap={1} wrap="wrap" style={{ marginBottom: 8 }}>
<HStack gap={2} vAlign="center">
<Text weight="bold">{previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'}</Text>

View File

@@ -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 {