updates on the ai bot and assigned by chatbot
This commit is contained in:
@@ -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 />}
|
||||
|
||||
@@ -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) ||============================== //
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
214
src/pages/nearle/assistant/assignActions.js
Normal file
214
src/pages/nearle/assistant/assignActions.js
Normal 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 }]
|
||||
};
|
||||
}
|
||||
};
|
||||
111
src/pages/nearle/assistant/assignFlow.js
Normal file
111
src/pages/nearle/assistant/assignFlow.js
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user