implemenation on the bot

This commit is contained in:
2026-08-19 17:08:45 +05:30
parent bdb21766f2
commit 37ca2352e2
52 changed files with 7074 additions and 3568 deletions

View File

@@ -20,7 +20,7 @@ import { StatusDot } from '@astryxdesign/core/StatusDot';
export default function PageHeader({ title, subtitle, live = false, action }) {
return (
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={2}>
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
<VStack gap={0.5} padding={0} minWidth={0}>
<Heading level={1} maxLines={1}>
{title}

View File

@@ -60,7 +60,7 @@ const FILL_CLASS = 'dt-page-fill';
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
// silently, which made every page-scoped rule fail to match with no error —
// the styles simply never applied.
export function PageShell({ children, gap = 6, fill = false, className }) {
export function PageShell({ children, gap = 4, fill = false, className }) {
return (
<>
{fill && (
@@ -105,10 +105,10 @@ PageShell.propTypes = {
// `max` caps the column count so 3 cards don't each become 400px wide on an
// ultrawide monitor; it defaults to the number of children.
// ---------------------------------------------------------------------------
export function KpiGrid({ children, minWidth = 240, max }) {
export function KpiGrid({ children, minWidth = 180, gap = 3, max }) {
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
return (
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={4}>
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>
{children}
</Grid>
);
@@ -117,6 +117,7 @@ export function KpiGrid({ children, minWidth = 240, max }) {
KpiGrid.propTypes = {
children: PropTypes.node,
minWidth: PropTypes.number,
gap: PropTypes.number,
max: PropTypes.number
};

View File

@@ -7,53 +7,42 @@ import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { Skeleton } from '@astryxdesign/core/Skeleton';
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== //
// Clean metric card: muted eyebrow label, large tabular value, a single
// soft-tinted rounded tile holding the icon (with a faint matching ring for
// depth). A low resting elevation lifts the tile off the page instead of
// reading as a flat bordered box. No coloured top-stripe, no rainbow —
// colour appears only in the icon tile so a row of cards reads calm and
// corporate.
// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
// Modern, space-efficient KPI tile:
// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
// - Soft ambient accent gradient tint based on semantic status color.
// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
// - Micro-hover interaction (subtle lift + shadow depth).
//
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
// `color` is a hex accent (defaults to brand red); `caption` is the small
// muted line under the value (e.g. "96% of total").
//
// Astryx note: the icon tile keeps an inline `style` because the accent is a
// per-call hex from the caller (each KPI tile carries its own semantic status
// colour — see CLAUDE.md's status palette), which Astryx's fixed `variant`
// and semantic `color` enums can't express. Same escape hatch login.js uses
// for the brand gradient panel.
// `color` is a hex accent (defaults to brand black); `caption` is optional supporting text.
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
return (
<Card height="100%" padding={2} elevation="low">
<HStack justify="between" vAlign="center" gap={2}>
<VStack gap={0.5} padding={0} minWidth={0} width="100%">
<Text type="supporting" size="xsm" weight="semibold" maxLines={1}>
{title}
</Text>
{loading ? (
<Skeleton width={60} height={26} radius={2} />
) : (
<Text size="xl" weight="bold" display="block" maxLines={1} hasTabularNumbers>
{value}
</Text>
)}
{caption && (
<Text type="supporting" size="sm" display="block">
{caption}
</Text>
)}
</VStack>
<Card
height="100%"
padding={1.5}
elevation="none"
className="dt-stat-card"
style={{
position: 'relative',
background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
border: `1px solid ${color}24`,
borderRadius: 'var(--radius-container, 6px)',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
cursor: 'default'
}}
>
<HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
{icon && (
<Center
width={36}
height={36}
width={34}
height={34}
className="dt-stat-icon"
style={{
flexShrink: 0,
borderRadius: 'var(--radius-element)',
backgroundColor: `${color}16`,
borderRadius: 'var(--radius-element, 6px)',
backgroundColor: `${color}14`,
color,
border: `1px solid ${color}2A`
}}
@@ -61,6 +50,51 @@ export default function StatCard({ title, value, icon, color = '#000000', captio
{icon}
</Center>
)}
<VStack gap={0} padding={0} minWidth={0} style={{ flex: 1 }}>
<Text
type="supporting"
size="xsm"
weight="semibold"
maxLines={1}
style={{
fontSize: '11.5px',
color: 'var(--color-text-supporting, #64748b)',
letterSpacing: '0.01em',
lineHeight: 1.2
}}
>
{title}
</Text>
{loading ? (
<Skeleton width={48} height={20} radius={2} style={{ marginTop: 2 }} />
) : (
<Text
size="lg"
weight="bold"
display="block"
maxLines={1}
hasTabularNumbers
style={{
fontSize: '19px',
lineHeight: 1.2,
color: 'var(--color-text-primary, #0f172a)',
marginTop: 1
}}
>
{value}
</Text>
)}
{caption && (
<Text
type="supporting"
size="xsm"
display="block"
style={{ fontSize: '11px', marginTop: 1 }}
>
{caption}
</Text>
)}
</VStack>
</HStack>
</Card>
);

View File

@@ -52,3 +52,22 @@ body {
*::-webkit-scrollbar-corner {
background-color: transparent;
}
/* KPI / StatCard sleek interactions & polish */
.dt-stat-card {
transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.18s ease !important;
}
.dt-stat-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04) !important;
}
.dt-stat-card .dt-stat-icon {
transition: transform 0.18s ease;
}
.dt-stat-card:hover .dt-stat-icon {
transform: scale(1.06);
}

View File

@@ -46,9 +46,14 @@ import {
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
// comment) are confirmed; the rest of this mapping is still a best-effort
// guess. Unmapped statuses pass through lowercased, which the page's own
// fallback renders as an "unknown" badge rather than crashing. There's no
// confirmed equivalent for arrived/picked/skipped at all, so those tabs will
// show a 0 count until the real enum is confirmed.
// fallback renders as an "unknown" badge rather than crashing, AND the
// Deliveries page now surfaces those rows under an "Other" tab that names the
// unmapped enum — previously such a row was counted nowhere and shown nowhere.
// `picked` now has a confirmed source (Converted_To_Consignment, below).
// `arrived` and `skipped` still have none: the rider actions that produce them
// (POST /miler/bookings/:id/reached, POST /miler/consignments/:id/skip) have no
// booking-status equivalent in the confirmed enum, so those two tabs stay at 0
// until the backend is confirmed to expose them.
//
// Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'.
// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler
@@ -62,11 +67,20 @@ import {
// accepted" in the currently-confirmed enum, so that one maps to 'accepted'.
// If the backend turns out to have a distinct status specifically for the
// accept action, add it here rather than reusing Miler_Assigned for it.
// Converted_To_Consignment is PICKED, not accepted. doormile-flow.md §5 is
// explicit: `POST /miler/bookings/:bookingid/pickup-complete` "converts the
// booking into a consignment" — so the status is written at the moment the
// rider completes pickup, and the parcel is in their hands. deliveries.js says
// the same thing from the other side ("this order hasn't been picked up yet —
// status can only be updated once it becomes a consignment").
//
// It sat on 'accepted', which held a picked-up parcel in the Accepted tab and
// left Picked permanently empty — nothing else in the enum maps to it.
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
pending_pickup: 'pending',
miler_assigned: 'pending',
pickup_scheduled: 'accepted',
converted_to_consignment: 'accepted',
converted_to_consignment: 'picked',
out_for_delivery: 'active',
delivered: 'delivered',
cancelled: 'cancelled'
@@ -135,7 +149,10 @@ export const getRiderPeriodicLogs = async (userid) => {
// seeing every hub, unchanged. Fails open (full list) whenever the tenant's
// city can't be determined or nothing matches, rather than ever locking an
// operator out with an empty picker.
const normCity = (s) => String(s || '').trim().toLowerCase();
const normCity = (s) =>
String(s || '')
.trim()
.toLowerCase();
export const fetchAppLocations = async () => {
try {
@@ -202,7 +219,16 @@ export const fetchPercentageData = async ({ queryKey }) => {
};
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000);
return { created: '0', uncoveredOrders: '0', coveredOrders: '0', cancelled: '0', percentage1: '0', percentage2: '0', percentage3: '0', percentage4: '0' };
return {
created: '0',
uncoveredOrders: '0',
coveredOrders: '0',
cancelled: '0',
percentage1: '0',
percentage2: '0',
percentage3: '0',
percentage4: '0'
};
}
};
@@ -327,7 +353,10 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
// jupiter2doormile.md comparison). Falls back to matching the rider's NAME
// (also echoed by the solver, see flattenRiders' rider_name) against each
// miler's displayname/authname — the only other correlatable field.
const normMilerName = (s) => String(s || '').trim().toLowerCase();
const normMilerName = (s) =>
String(s || '')
.trim()
.toLowerCase();
export const buildMilerLookup = (milers) => {
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
@@ -348,10 +377,10 @@ export const resolveMilerForOrder = (order, lookup) => {
const matchedVia = lookup.byUserId.has(String(riderUserId))
? 'userid'
: lookup.byProfileId.has(String(riderUserId))
? 'milerprofileid'
: lookup.byName.has(normMilerName(riderName))
? 'name'
: null;
? 'milerprofileid'
: lookup.byName.has(normMilerName(riderName))
? 'name'
: null;
const rider =
lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName));
return rider?.milerprofileid ? { rider, matchedVia } : null;
@@ -407,7 +436,11 @@ export const finalCreatedeliveries = async (deliveryData) => {
realBookingIds = new Set(realBookings.map((b) => String(b.bookingid)));
logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`);
} catch (err) {
logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message);
logger.error(
'finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids',
err.response?.status,
err.response?.data || err.message
);
}
const results = await Promise.allSettled(
@@ -464,11 +497,7 @@ export const finalCreatedeliveries = async (deliveryData) => {
if (failed.length) {
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
}
const resolvedMilerProfileIds = [
...new Set(
results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid)
)
];
const resolvedMilerProfileIds = [...new Set(results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid))];
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds };
};
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
@@ -536,7 +565,11 @@ 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.
const [, , , , startdate, enddate, rowsPerPage] = queryKey;
// 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;
// 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
@@ -665,14 +698,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;
};
const inRange = (row) => {
if (!startdate && !enddate) return true;
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;
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)));
};
return {
@@ -815,9 +857,7 @@ export const getallridersummary = async () => {
// silently inflating one of the two new buckets.
const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length;
const onDelivery = milers.filter((m) =>
['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())
).length;
const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
return { total: milers.length, active, inactive: milers.length - active, available, onDelivery };
} catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000);

View File

@@ -16,6 +16,8 @@ An in-console Q&A assistant that answers operator questions about live data —
- `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
- `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
- `pageContext.js` — route → context label + suggested questions.
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
@@ -25,7 +27,10 @@ An in-console Q&A assistant that answers operator questions about live data —
- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
---
@@ -36,7 +41,23 @@ This was a deliberate, explicit product decision (not a technical limitation wor
Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
RAG (retrieval-augmented generation over a document/vector store) was also explicitly considered and rejected as the wrong tool: this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions. The correct "grounded answer" pattern for that is what's already here — a fixed catalog of `{match, run}` pairs, not a vector search.
### RAG — rejected for the data, later built for the ROUTING
RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
```
question → embed → Chroma → intentId + confidence → the SAME run() → live API call
```
Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
Three rules that must hold:
- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
- **Write intents need high confidence.** A semantic near-miss must never open a create form.
---
@@ -81,6 +102,8 @@ A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, a
- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
### Ordering and cross-domain guards — read before adding an intent
@@ -112,22 +135,102 @@ If you add a new intent whose trigger words could plausibly appear in an unrelat
---
### Customer creation writes to `/admin/customers`
### Customer creation writes to `/admin/tenantcustomers`
`executeCreateCustomer` posts to `/admin/customers` — the same resource the Customers page reads — so a created customer appears there straight away.
Settled by evidence, not by reading the docs:
It previously posted to `/admin/tenantcustomers`. That call succeeded but wrote to a **different store with its own id sequence**: a customer created through the bot came back as id 2 while the Customers list ran to id 44, and never showed on the page. Repointing the *page* at `tenantcustomers` was tried first and reverted — it changes what that page means, and its edit dialog would then PATCH a different store by id (404 at best, editing the wrong person at worst).
```
POST /admin/customers → 405 Method Not Allowed (confirmed live)
```
Two things to know if this ever needs revisiting:
405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.**
- `express-console-api.md` documents `/admin/customers` as **GET + PATCH only**, and describes a customer there as something a booking creates (`POST /admin/expressbooking` — *"creates a Guest customer if unknown"*). The POST was wired on explicit instruction regardless. If the route is absent the server answers 404/405 and the assistant surfaces it verbatim with the endpoint name, so an unsupported route fails loudly rather than silently.
- The sidebar's **Create Customer page** (`clients/createCustomer.js`) still calls `createTenantCustomer`. It and the bot now write to different stores. If the bot's endpoint is correct, that page should follow.
**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
---
## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
### The panel drives the conversation, not the router
`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
### One engine, three flows
The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
Step entries carry:
| key | meaning |
|---|---|
| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
| `type: 'text'` | answered through the composer. |
| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
### Two silent-NaN traps that were live
- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
### Bulk — a conversation, then one long pass
Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
### One row pipeline, two inputs
A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
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.
---
## 4. What's deliberately out of scope right now
- **Write actions.** The original ask included "if I say create an order, it should create it" — deliberately **not built**. Giving a keyword-matched bot the ability to mutate data (order creation has real validation elsewhere: CityGate pincode checks, delivery-slot windows, the dispatch reconcile-before-commit rule) is a materially bigger risk than read-only Q&A. If this gets built, it needs its own guardrail — the bot proposes what it would submit, the operator explicitly confirms, only then does a real create-order call fire. Don't wire a write action straight from intent match to a mutation call.
- **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.
- **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

@@ -44,7 +44,6 @@
--dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
}
/* --------------------------------------------------------------------------
Scrim — deliberately light. The dashboard underneath must stay readable;
this is a layering cue, not a modal blackout.
@@ -57,11 +56,9 @@
opacity: 0;
transition: opacity var(--dai-duration) var(--dai-ease);
}
.dai-scrim[data-open='true'] {
opacity: 1;
}
/* --------------------------------------------------------------------------
Panel
-------------------------------------------------------------------------- */
@@ -87,16 +84,13 @@
transform var(--dai-duration) var(--dai-ease),
opacity var(--dai-duration) var(--dai-ease);
}
.dai-panel[data-open='true'] {
transform: translateX(0);
opacity: 1;
}
.dai-panel:focus {
outline: none;
}
/* --------------------------------------------------------------------------
Header
-------------------------------------------------------------------------- */
@@ -105,7 +99,6 @@
padding: 14px 12px 12px 14px;
border-bottom: 1px solid var(--dai-border);
}
.dai-root .dai-title {
font-size: 15px;
font-weight: 650;
@@ -113,13 +106,11 @@
letter-spacing: -0.01em;
color: var(--dai-text);
}
.dai-root .dai-subtitle {
font-size: 12px;
line-height: 1.3;
color: var(--dai-text-muted);
}
/* The AI mark. A soft gradient orb — not a robot face. */
.dai-root .dai-spark {
display: inline-flex;
@@ -127,27 +118,34 @@
justify-content: center;
flex: 0 0 auto;
border-radius: 999px;
color: #ffffff;
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
box-shadow: 0 0 0 3px var(--dai-ai-glow);
background: var(--dai-surface);
overflow: hidden;
}
/* The mark's own canvas is only 66.8% content — a third of every edge is
transparent padding, measured off the PNG's alpha bounding box (x and y both
170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore
rendered a D two-thirds the size the box implied, which is exactly why it
read as too small. 150% cancels that padding so the D fills its box edge to
edge; the overflow:hidden above clips only transparent pixels. */
.dai-root .dai-spark img {
width: 150%;
height: 150%;
max-width: none;
object-fit: contain;
display: block;
}
.dai-root .dai-spark[data-size='sm'] {
width: 22px;
height: 22px;
}
.dai-root .dai-spark[data-size='md'] {
width: 30px;
height: 30px;
}
.dai-root .dai-spark[data-size='lg'] {
width: 44px;
height: 44px;
box-shadow: 0 0 0 6px var(--dai-ai-glow);
.dai-root .dai-spark[data-size='md'] {
width: 40px;
height: 40px;
}
.dai-root .dai-spark[data-size='lg'] {
width: 56px;
height: 56px;
}
/* Live indicator — subtle, not a large pill. */
.dai-root .dai-live {
display: inline-flex;
@@ -161,7 +159,6 @@
background: rgba(16, 185, 129, 0.08);
white-space: nowrap;
}
.dai-root .dai-live-dot {
width: 6px;
height: 6px;
@@ -169,7 +166,6 @@
background: var(--dai-live);
animation: dai-pulse 2.4s ease-in-out infinite;
}
@keyframes dai-pulse {
0%,
100% {
@@ -181,7 +177,6 @@
transform: scale(0.85);
}
}
/* Page-context strip — "Orders · Today · All locations" */
.dai-root .dai-context {
flex: 0 0 auto;
@@ -194,7 +189,120 @@
overflow: hidden;
text-overflow: ellipsis;
}
/* --------------------------------------------------------------------------
Welcome state
-------------------------------------------------------------------------- */
.dai-root .dai-welcome {
padding: 22px 14px 8px;
}
.dai-root .dai-welcome-greeting {
font-size: 17px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--dai-text);
}
.dai-root .dai-welcome-lead {
font-size: 13.5px;
line-height: 1.5;
color: var(--dai-text-secondary);
}
.dai-root .dai-welcome-note {
font-size: 12px;
line-height: 1.5;
color: var(--dai-text-muted);
}
.dai-root .dai-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--dai-text-muted);
}
/* --------------------------------------------------------------------------
Suggestion cards
-------------------------------------------------------------------------- */
.dai-suggestion {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 11px;
text-align: left;
font: inherit;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 5px;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
}
.dai-suggestion:active {
transform: scale(0.99);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 5px;
background: var(--dai-surface-hover);
color: var(--dai-text-secondary);
}
.dai-root .dai-suggestion-text {
flex: 1 1 auto;
font-size: 13px;
line-height: 1.35;
}
.dai-root .dai-suggestion-arrow {
flex: 0 0 auto;
color: var(--dai-text-muted);
opacity: 0;
transform: translateX(-3px);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-arrow,
.dai-suggestion:focus-visible .dai-suggestion-arrow {
opacity: 1;
transform: translateX(0);
}
/* Plain text link-button ("View more", "Sources") */
.dai-link {
font: inherit;
font-size: 12px;
color: var(--dai-text-secondary);
background: none;
border: none;
padding: 2px 0;
cursor: pointer;
align-self: flex-start;
}
.dai-link:hover {
color: var(--dai-text);
text-decoration: underline;
}
.dai-link:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
border-radius: 5px;
}
/* --------------------------------------------------------------------------
Conversation
-------------------------------------------------------------------------- */
/* --------------------------------------------------------------------------
Scroll region
-------------------------------------------------------------------------- */
@@ -225,141 +333,24 @@
background: transparent;
}
/* --------------------------------------------------------------------------
Welcome state
-------------------------------------------------------------------------- */
.dai-root .dai-welcome {
padding: 22px 14px 8px;
}
.dai-root .dai-welcome-greeting {
font-size: 17px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--dai-text);
}
.dai-root .dai-welcome-lead {
font-size: 13.5px;
line-height: 1.5;
color: var(--dai-text-secondary);
}
.dai-root .dai-welcome-note {
font-size: 12px;
line-height: 1.5;
color: var(--dai-text-muted);
}
.dai-root .dai-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--dai-text-muted);
}
/* --------------------------------------------------------------------------
Suggestion cards
-------------------------------------------------------------------------- */
.dai-suggestion {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 11px;
text-align: left;
font: inherit;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 5px;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
}
.dai-suggestion:active {
transform: scale(0.99);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 5px;
background: var(--dai-surface-hover);
color: var(--dai-text-secondary);
}
.dai-root .dai-suggestion-text {
.dai-scroll-wrap {
position: relative;
flex: 1 1 auto;
font-size: 13px;
line-height: 1.35;
min-height: 0;
display: flex;
flex-direction: column;
}
.dai-root .dai-suggestion-arrow {
flex: 0 0 auto;
color: var(--dai-text-muted);
opacity: 0;
transform: translateX(-3px);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-arrow,
.dai-suggestion:focus-visible .dai-suggestion-arrow {
opacity: 1;
transform: translateX(0);
}
/* Plain text link-button ("View more", "Sources") */
.dai-link {
font: inherit;
font-size: 12px;
color: var(--dai-text-secondary);
background: none;
border: none;
padding: 2px 0;
cursor: pointer;
align-self: flex-start;
}
.dai-link:hover {
color: var(--dai-text);
text-decoration: underline;
}
.dai-link:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
border-radius: 5px;
}
/* --------------------------------------------------------------------------
Conversation
-------------------------------------------------------------------------- */
.dai-root .dai-thread {
padding: 16px 14px 8px;
}
.dai-root .dai-msg {
/* Only the newest turn animates in. Applying this to every .dai-msg meant
opening a panel with a restored thread started 50 simultaneous opacity +
transform animations, which is a visible hitch before the first scroll. The
base state IS the animation's end state, so a turn that stops being last
simply keeps its finished appearance. */
.dai-root .dai-thread > .dai-msg:last-child {
animation: dai-enter 220ms var(--dai-ease) both;
}
@@ -374,7 +365,6 @@
}
}
/* User — compact, right aligned, brand surface. */
.dai-root .dai-msg-user {
max-width: 82%;
margin-left: auto;
@@ -388,7 +378,6 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Assistant — no bubble. Text sits on the panel surface. */
.dai-root .dai-msg-ai {
font-size: 13.5px;
@@ -396,36 +385,30 @@
color: var(--dai-text);
overflow-wrap: anywhere;
}
.dai-root .dai-msg-ai-detail {
font-size: 12.5px;
line-height: 1.55;
color: var(--dai-text-secondary);
white-space: pre-line;
}
.dai-root .dai-msg-name {
font-size: 11.5px;
font-weight: 600;
color: var(--dai-text-secondary);
}
.dai-root .dai-msg-footer {
font-size: 11px;
color: var(--dai-text-muted);
}
/* Copy button — only revealed on hover of the message row. */
.dai-root .dai-msg-actions {
opacity: 0;
transition: opacity 140ms ease;
}
.dai-root .dai-msg-row:hover .dai-msg-actions,
.dai-root .dai-msg-row:focus-within .dai-msg-actions {
opacity: 1;
}
/* --------------------------------------------------------------------------
Structured metrics
-------------------------------------------------------------------------- */
@@ -435,14 +418,12 @@
gap: 6px;
width: 100%;
}
.dai-root .dai-stat {
padding: 9px 10px;
border: 1px solid var(--dai-border);
border-radius: 5px;
background: var(--dai-surface-alt);
}
.dai-root .dai-stat-value {
font-size: 19px;
font-weight: 650;
@@ -450,12 +431,10 @@
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.dai-root .dai-stat-label {
font-size: 11px;
color: var(--dai-text-muted);
}
/* Headline metric — the "primary number is large" rule. */
.dai-root .dai-metric-value {
font-size: 28px;
@@ -465,12 +444,10 @@
color: var(--dai-text);
font-variant-numeric: tabular-nums;
}
.dai-root .dai-metric-label {
font-size: 12px;
color: var(--dai-text-secondary);
}
/* --------------------------------------------------------------------------
Typing / loading
-------------------------------------------------------------------------- */
@@ -480,7 +457,6 @@
gap: 4px;
height: 18px;
}
.dai-root .dai-typing span {
width: 5px;
height: 5px;
@@ -488,15 +464,12 @@
background: var(--dai-text-muted);
animation: dai-bounce 1.3s ease-in-out infinite;
}
.dai-root .dai-typing span:nth-child(2) {
animation-delay: 0.16s;
}
.dai-root .dai-typing span:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes dai-bounce {
0%,
70%,
@@ -509,7 +482,6 @@
transform: translateY(-3px);
}
}
.dai-root .dai-shimmer {
height: 9px;
border-radius: 999px;
@@ -517,7 +489,6 @@
background-size: 400% 100%;
animation: dai-shimmer 1.5s ease infinite;
}
@keyframes dai-shimmer {
from {
background-position: 100% 50%;
@@ -526,7 +497,6 @@
background-position: 0 50%;
}
}
/* --------------------------------------------------------------------------
States (error / empty)
-------------------------------------------------------------------------- */
@@ -536,19 +506,16 @@
border-radius: 5px;
background: var(--dai-surface-alt);
}
.dai-root .dai-state-title {
font-size: 13px;
font-weight: 600;
color: var(--dai-text);
}
.dai-root .dai-state-body {
font-size: 12.5px;
line-height: 1.5;
color: var(--dai-text-secondary);
}
.dai-root .dai-state-icon {
display: inline-flex;
align-items: center;
@@ -558,7 +525,6 @@
border-radius: 5px;
flex: 0 0 auto;
}
/* --------------------------------------------------------------------------
Jump-to-latest
-------------------------------------------------------------------------- */
@@ -581,24 +547,13 @@
cursor: pointer;
z-index: 2;
}
.dai-jump:hover {
background: var(--dai-surface-alt);
}
.dai-jump:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-scroll-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* --------------------------------------------------------------------------
Composer
-------------------------------------------------------------------------- */
@@ -608,7 +563,6 @@
border-top: 1px solid var(--dai-border);
background: var(--dai-surface);
}
.dai-root .dai-composer {
border: 1px solid var(--dai-border-strong);
border-radius: 5px;
@@ -619,12 +573,10 @@
border-color 140ms ease,
box-shadow 140ms ease;
}
.dai-root .dai-composer[data-focused='true'] {
border-color: var(--dai-accent);
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06);
}
.dai-root .dai-composer textarea {
display: block;
width: 100%;
@@ -641,51 +593,13 @@
max-height: 108px; /* ~5 lines */
overflow-y: auto;
}
.dai-root .dai-composer textarea::placeholder {
color: var(--dai-text-muted);
}
.dai-send {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border: none;
border-radius: 999px;
color: var(--dai-accent-contrast);
background: var(--dai-accent);
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.dai-send:hover:not(:disabled) {
opacity: 0.88;
}
.dai-send:active:not(:disabled) {
transform: scale(0.93);
}
.dai-send:disabled {
opacity: 0.28;
cursor: default;
}
.dai-send:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-root .dai-hint {
font-size: 11px;
color: var(--dai-text-muted);
}
/* --------------------------------------------------------------------------
Trigger (lives in the app TopNav)
-------------------------------------------------------------------------- */
@@ -693,40 +607,44 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 40px;
height: 40px;
padding: 0;
border-radius: 5px;
border: 1px solid transparent;
background: transparent;
color: #4f46e5;
cursor: pointer;
overflow: hidden;
transition:
background-color 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.dai-trigger:hover {
background: rgba(99, 102, 241, 0.09);
border-color: rgba(99, 102, 241, 0.16);
/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
30px inside the 40px button. */
.dai-trigger-mark {
width: 30px;
height: 30px;
max-width: none;
object-fit: contain;
display: block;
transform: scale(1.5);
}
.dai-trigger:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.1);
}
.dai-trigger:active {
transform: scale(0.94);
}
.dai-trigger:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-trigger[data-active='true'] {
color: #ffffff;
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
border-color: transparent;
background: rgba(0, 0, 0, 0.07);
border-color: rgba(0, 0, 0, 0.14);
}
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@@ -735,24 +653,17 @@
--dai-panel-width: 396px;
}
}
@media (max-width: 767px) {
.dai-root {
--dai-inset: 0px;
}
.dai-panel {
width: 100vw;
max-width: 100vw;
border-radius: 0;
border: none;
}
.dai-msg-user {
max-width: 88%;
}
}
/* --------------------------------------------------------------------------
Reduced motion — no slide, no pulse, no shimmer. Opacity only.
-------------------------------------------------------------------------- */
@@ -761,11 +672,9 @@
.dai-scrim {
transition: opacity 1ms linear;
}
.dai-panel {
transform: none;
}
.dai-msg,
.dai-live-dot,
.dai-typing span,
@@ -777,7 +686,6 @@
transition: none !important;
}
}
/* --------------------------------------------------------------------------
Write-action confirm card
-------------------------------------------------------------------------- */
@@ -787,7 +695,6 @@
border-radius: 5px;
background: var(--dai-surface-alt);
}
.dai-root .dai-action-icon {
display: inline-flex;
align-items: center;
@@ -799,28 +706,22 @@
color: #4f46e5;
background: rgba(99, 102, 241, 0.1);
}
.dai-root .dai-action-summary {
font-size: 13px;
line-height: 1.35;
font-weight: 550;
color: var(--dai-text);
}
.dai-root .dai-action-note {
font-size: 12px;
color: var(--dai-text-secondary);
}
.dai-root .dai-ok {
color: #047857;
}
.dai-root .dai-err {
color: #b91c1c;
}
/* --------------------------------------------------------------------------
Overflow containment
--------------------------------------------------------------------------
@@ -838,7 +739,6 @@
min-width: 0;
max-width: 100%;
}
/* Two fields per row that genuinely share the width. `flex-wrap` is the
belt-and-braces part: if a field ever can't compress far enough (a long
label, a narrower panel), the row drops to two lines instead of pushing the
@@ -850,12 +750,10 @@
width: 100%;
min-width: 0;
}
.dai-root .dai-form-row > * {
flex: 1 1 140px;
min-width: 0;
}
/* Astryx's TextInput sizes itself to content unless told otherwise. */
.dai-root .dai-form input,
.dai-root .dai-form-row input {
@@ -863,34 +761,62 @@
min-width: 0;
box-sizing: border-box;
}
.dai-root .dai-field-err {
font-size: 11.5px;
line-height: 1.4;
color: #b91c1c;
}
/* --------------------------------------------------------------------------
Source footer + full lists
-------------------------------------------------------------------------- */
/* The footer is a baseline-aligned row, not a centred one: the timestamp and
the Sources toggle are both small text and must sit on the same line. */
/* Timestamp left, Sources toggle hard right, both on ONE line. It was a
wrapping baseline row, and the toggle — a shrinkable flex item with inline
content — gave up its chevron to a second line whenever the panel got
narrow, which read as a stray arrow floating under the text. */
.dai-root .dai-msg-foot {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
gap: 10px;
width: 100%;
min-width: 0;
}
/* The timestamp is the only part allowed to give up space, and it ellipsises
rather than wrapping. */
.dai-root .dai-msg-foot .dai-msg-footer {
flex: 0 1 auto;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* The toggle is one atom: label and chevron cannot be separated. */
.dai-root .dai-msg-foot .dai-sources {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
align-self: center;
white-space: nowrap;
padding: 3px 7px;
margin-right: -7px;
border-radius: 5px;
color: var(--dai-text-muted);
transition: background 140ms ease, color 140ms ease;
}
.dai-root .dai-msg-foot .dai-sources:hover {
background: var(--dai-surface-hover);
color: var(--dai-text);
text-decoration: none;
}
.dai-root .dai-msg-foot .dai-sources svg {
flex: 0 0 auto;
transition: transform 160ms var(--dai-ease);
}
.dai-root .dai-msg-foot .dai-sources[aria-expanded='true'] svg {
transform: rotate(180deg);
}
/* A full, readable list instead of "…and 4 more". Scrolls past ~12 rows so a
long list can never push the panel's height around. */
.dai-root .dai-list {
@@ -901,7 +827,6 @@
background: var(--dai-surface-alt);
overflow: hidden;
}
.dai-root .dai-list-head {
display: flex;
align-items: baseline;
@@ -915,14 +840,12 @@
text-transform: uppercase;
color: var(--dai-text-muted);
}
.dai-root .dai-list-body {
max-height: 260px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
}
.dai-root .dai-list-item {
display: flex;
flex-wrap: wrap;
@@ -934,11 +857,9 @@
color: var(--dai-text);
border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05));
}
.dai-root .dai-list-item:first-child {
border-top: none;
}
.dai-root .dai-list-index {
flex: 0 0 auto;
min-width: 18px;
@@ -946,7 +867,6 @@
font-variant-numeric: tabular-nums;
color: var(--dai-text-muted);
}
/* The label must not be crushed to zero width by a long value — that is what
made "address" render one letter per line. It sizes to its content and the
value takes the remaining space, wrapping onto its own full-width line when
@@ -956,7 +876,6 @@
min-width: 0;
overflow-wrap: anywhere;
}
.dai-root .dai-list-meta {
flex: 1 1 auto;
min-width: 0;
@@ -965,10 +884,36 @@
color: var(--dai-text-secondary);
overflow-wrap: anywhere;
}
/* A value that wrapped to its own line reads better left-aligned under its
label than pinned to the right edge. */
.dai-root .dai-list-item > .dai-list-meta:only-child,
.dai-root .dai-list-meta[data-wrapped='true'] {
text-align: left;
}
/* --------------------------------------------------------------------------
Documentation answers
--------------------------------------------------------------------------
Retrieved passages are shown VERBATIM with their source file. They are prose
rather than a figure, so they get a quieter, wider treatment than a metric —
and the source line matters as much as the text, because the operator needs
to know this came from a document, not from live data.
-------------------------------------------------------------------------- */
.dai-root .dai-doc {
padding: 10px 11px;
border: 1px solid var(--dai-border);
border-left: 2px solid var(--dai-ai-from);
border-radius: 5px;
background: var(--dai-surface-alt);
font-size: 12.5px;
line-height: 1.55;
color: var(--dai-text);
white-space: pre-line;
overflow-wrap: anywhere;
max-height: 320px;
overflow-y: auto;
}
.dai-root .dai-doc-source {
font-size: 11px;
color: var(--dai-text-muted);
font-variant-numeric: tabular-nums;
}

View File

@@ -1,193 +0,0 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
// ==============================|| Doormile AI — create-customer form ||============================== //
//
// Mirrors pages/nearle/clients/createCustomer.js field-for-field, including
// the address block, so the assistant asks for the same things the page does.
//
// Two corrections came out of a real GET /admin/customers response:
//
// • NO TENANT. The documented POST /admin/tenantcustomers body is
// { firstname, lastname, phone, email } and a customer record carries no
// tenantid at all. The tenant field this form used to show was speculative
// and has been removed.
// • The record DOES carry address, doorno, landmark, suburb, city, state,
// postcode, latitude and longitude — they exist on the model, contrary to
// the "no address field exists" comments in customers.js and
// createCustomer.js. They come back empty on every live record, and the
// documented create body doesn't list them, so they are sent here on a
// best-effort basis: if the server ignores them nothing breaks, and the
// first real create will settle whether they persist.
//
// The form IS the write gate. Every value that will be sent is visible and
// editable, and nothing is submitted until Create is pressed.
const PHONE_RE = /^\d{10}$/;
// Nominatim results are shaped like Google Places by AddressAutocomplete's
// `toPlace`, so this reads the same component types createCustomer.js reads.
const fromPlace = (place) => {
const out = { latitude: place.geometry?.location?.lat?.(), longitude: place.geometry?.location?.lng?.() };
(place.address_components || []).forEach((c) => {
const types = c.types || [];
if (types.includes('sublocality_level_1') || types.includes('sublocality')) out.suburb = c.long_name;
if (types.includes('locality')) out.city = c.long_name;
if (types.includes('administrative_area_level_1')) out.state = c.long_name;
if (types.includes('postal_code')) out.postcode = c.long_name;
});
return out;
};
const AICustomerForm = ({ initial, status, error, onSubmit, onCancel }) => {
const [values, setValues] = useState({
firstname: initial?.firstname || '',
lastname: initial?.lastname || '',
phone: initial?.phone || '',
email: initial?.email || '',
address: '',
doorno: '',
landmark: '',
suburb: '',
city: '',
state: '',
postcode: '',
latitude: undefined,
longitude: undefined
});
const [touched, setTouched] = useState({});
const set = (key) => (v) => setValues((prev) => ({ ...prev, [key]: v }));
const blur = (key) => () => setTouched((prev) => ({ ...prev, [key]: true }));
const errors = {
firstname: values.firstname.trim() ? null : 'Required',
phone: PHONE_RE.test(values.phone.trim()) ? null : 'Enter exactly 10 digits'
};
const isValid = !errors.firstname && !errors.phone;
const isDone = status === 'done';
const isBusy = status === 'running';
const isLocked = isDone || status === 'cancelled' || status === 'stale';
if (isLocked) {
return (
<VStack className="dai-action" gap={1} padding={0}>
<Text className="dai-action-summary">
{[values.firstname, values.lastname].filter(Boolean).join(' ')} · {values.phone}
</Text>
<Text className={`dai-action-note${isDone ? ' dai-ok' : ''}`}>
{isDone ? '✓ Created' : status === 'cancelled' ? 'Cancelled — nothing was sent.' : 'Expired — ask again to recreate this.'}
</Text>
</VStack>
);
}
return (
<VStack className="dai-action dai-form" gap={2} padding={0}>
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput
label="First name"
size="sm"
value={values.firstname}
onChange={set('firstname')}
onBlur={blur('firstname')}
placeholder="Ramesh"
isDisabled={isBusy}
/>
<TextInput label="Last name" size="sm" value={values.lastname} onChange={set('lastname')} placeholder="Kumar" isDisabled={isBusy} />
</HStack>
{touched.firstname && errors.firstname && <Text className="dai-field-err">First name — {errors.firstname}</Text>}
<TextInput
label="Mobile number"
size="sm"
value={values.phone}
onChange={set('phone')}
onBlur={blur('phone')}
placeholder="9876543210"
isDisabled={isBusy}
/>
{touched.phone && errors.phone && <Text className="dai-field-err">Mobile — {errors.phone}</Text>}
<TextInput
label="Email (optional)"
size="sm"
value={values.email}
onChange={set('email')}
placeholder="ramesh@example.com"
isDisabled={isBusy}
/>
{/* Address search fills suburb / city / state / postcode and the
coordinates in one go, the same way the create-customer page does. */}
<AddressAutocomplete
label="Address"
placeholder="Search address"
value={values.address}
onChange={set('address')}
onPlaceSelected={(place) =>
setValues((prev) => ({ ...prev, address: place.formatted_address || prev.address, ...fromPlace(place) }))
}
disabled={isBusy}
/>
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput label="Door no" size="sm" value={values.doorno} onChange={set('doorno')} placeholder="12A" isDisabled={isBusy} />
<TextInput
label="Landmark"
size="sm"
value={values.landmark}
onChange={set('landmark')}
placeholder="Near the temple"
isDisabled={isBusy}
/>
</HStack>
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput label="Suburb" size="sm" value={values.suburb} onChange={set('suburb')} placeholder="Gandhipuram" isDisabled={isBusy} />
<TextInput label="City" size="sm" value={values.city} onChange={set('city')} placeholder="Coimbatore" isDisabled={isBusy} />
</HStack>
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput label="State" size="sm" value={values.state} onChange={set('state')} placeholder="Tamil Nadu" isDisabled={isBusy} />
<TextInput label="Postcode" size="sm" value={values.postcode} onChange={set('postcode')} placeholder="641012" isDisabled={isBusy} />
</HStack>
<Text className="dai-action-note">
Name and mobile are required. The create endpoint documents only name, phone and email — the address is sent too, but may not be
stored.
</Text>
{status === 'failed' && error && <Text className="dai-field-err">{error}</Text>}
<HStack gap={1.5} padding={0}>
<Button
label={isBusy ? 'Creating…' : 'Create customer'}
variant="primary"
size="sm"
isDisabled={!isValid || isBusy}
onClick={() => onSubmit(values)}
/>
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
</HStack>
</VStack>
);
};
AICustomerForm.propTypes = {
initial: PropTypes.object,
status: PropTypes.string,
error: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired
};
export default AICustomerForm;

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
// ==============================|| Doormile AI — one dropdown step ||============================== //
//
// Rendered when a conversational step is a `select`. The Create Order page uses
// dropdowns for business location, category, weight and service tier, so the
// bot does too — asking an operator to TYPE a location name they'd otherwise
// pick from a list is slower and invites a name the resolver can't match.
//
// Options load asynchronously (locations, customers and tenants are fetched
// live) so the list is never stale or invented. Two states matter and are
// distinguished rather than merged into one spinner:
//
// • loading — still fetching
// • empty — fetched successfully and there genuinely is nothing to pick,
// which is a dead end the operator has to be told about (a
// tenant with no saved locations cannot have an order created)
const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
const [options, setOptions] = useState(null);
const [value, setValue] = useState('');
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setOptions(null);
setFailed(false);
setValue('');
Promise.resolve(step.options?.(draft) ?? [])
.then((list) => alive && setOptions(list))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step.id]);
if (failed) {
return (
<VStack className="dai-action" gap={1} padding={0}>
<Text className="dai-field-err">I couldn’t load the options for that. Say “cancel” and try again.</Text>
</VStack>
);
}
if (options === null) {
return (
<VStack className="dai-action" gap={1} padding={0}>
<Text className="dai-action-note">Loading options…</Text>
</VStack>
);
}
if (!options.length) {
return (
<VStack className="dai-action" gap={1.5} padding={0}>
<Text className="dai-field-err">
{step.id === 'pickuplocationid'
? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.'
: 'There’s nothing to choose from here.'}
</Text>
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
</VStack>
);
}
const chosen = options.find((o) => String(o.value) === String(value));
return (
<VStack className="dai-action" gap={1.5} padding={0}>
<Selector
label={step.ask}
isLabelHidden
placeholder="Choose one"
options={options.map((o) => ({ value: String(o.value), label: o.label }))}
value={value}
onChange={setValue}
// A long customer list is unusable without search.
hasSearch={options.length > 8}
isDisabled={isBusy}
/>
<HStack gap={1.5} padding={0}>
<Button
label="Continue"
variant="primary"
size="sm"
isDisabled={!value || isBusy}
// The whole option is passed back, not just its value — steps that
// pick a record (a location, a customer) need the record itself to
// fill several fields at once.
onClick={() => onChoose(value, chosen)}
/>
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
</HStack>
</VStack>
);
};
AIFlowStep.propTypes = {
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string, options: PropTypes.func }).isRequired,
onChoose: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
draft: PropTypes.object,
isBusy: PropTypes.bool
};
export default AIFlowStep;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { memo, useState } from 'react';
import PropTypes from 'prop-types';
import { CopyOutlined } from '@ant-design/icons';
import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
@@ -7,11 +7,13 @@ import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Button } from '@astryxdesign/core/Button';
import { ChatToolCalls } from '@astryxdesign/core/Chat';
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
import AICustomerForm from './AICustomerForm';
import AIOrderForm from './AIOrderForm';
import AIFlowStep from './AIFlowStep';
import AIRowsStep from './AIRowsStep';
import { failedRowsCsv, downloadCsv } from '../bulkFile';
// ==============================|| Doormile AI — a single turn ||============================== //
//
@@ -32,7 +34,7 @@ const UserMessage = ({ text }) => (
UserMessage.propTypes = { text: PropTypes.string.isRequired };
const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction }) => {
const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
const [showSources, setShowSources] = useState(false);
const sourceCount = message.sourceCalls?.length || 0;
@@ -71,7 +73,12 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
{message.list && <AnswerList title={message.list.title} items={message.list.items} numbered={message.list.numbered !== false} />}
{message.detail && <Text className="dai-msg-ai-detail">{message.detail}</Text>}
{message.detail &&
(message.isDoc ? (
<Text className="dai-doc">{message.detail}</Text>
) : (
<Text className="dai-msg-ai-detail">{message.detail}</Text>
))}
</VStack>
)}
@@ -81,13 +88,15 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
<VStack gap={1} padding={0}>
<HStack className="dai-msg-foot" gap={0} padding={0}>
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
<button type="button" className="dai-link" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
<LuChevronDown
size={11}
style={{ marginLeft: 3, verticalAlign: '-1px', transform: showSources ? 'rotate(180deg)' : 'none' }}
aria-hidden="true"
/>
{/* The chevron's offset and rotation live in the stylesheet — as inline
styles they couldn't be transitioned, and `verticalAlign` only
works while the button's content is inline, which is exactly the
thing that made it wrap. */}
<button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
<span>
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
</span>
<LuChevronDown size={11} aria-hidden="true" />
</button>
</HStack>
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
@@ -100,25 +109,75 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
localStorage in a later session is marked 'stale' by the panel and
renders read-only, so a write can never fire from a resurrected
message. */}
{message.form?.kind === 'createOrder' && (
<AIOrderForm
status={message.form.status}
error={message.form.error}
onSubmit={(values) => onSubmitForm(message, values)}
{/* A conversational step that needs a dropdown rather than typing. */}
{message.liveStop && (
<VStack className="dai-action" gap={1} padding={0}>
<Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
</VStack>
)}
{/* The bulk create's rows turn — a whole file in one answer. */}
{message.flowStep?.type === 'rows' && !message.flowStep.__answered && (
<AIRowsStep
isBusy={message.flowStep.__answered}
onSubmit={(parsed) => onChooseStep(message, parsed)}
onCancel={() => onCancelAction(message)}
/>
)}
{message.form?.kind === 'createCustomer' && (
<AICustomerForm
initial={message.form.initial}
status={message.form.status}
error={message.form.error}
onSubmit={(values) => onSubmitForm(message, values)}
{message.flowStep?.type === 'select' && (
<AIFlowStep
step={message.flowStep}
draft={message.flowDraft}
isBusy={message.flowStep.__answered}
onChoose={(value, option) => onChooseStep(message, value, option)}
onCancel={() => onCancelAction(message)}
/>
)}
{(message.form?.kind === 'confirmCustomer' || message.form?.kind === 'confirmOrder' || message.form?.kind === 'confirmBulk') && (
<VStack className="dai-action" gap={1.5} padding={0}>
{message.form.status === 'open' ? (
<HStack gap={1.5} padding={0}>
<Button
label={
message.form.kind === 'confirmBulk'
? `Create ${message.form.draft?.rows?.length || 0} orders`
: message.form.kind === 'confirmOrder'
? 'Create order'
: 'Submit'
}
variant="primary"
size="sm"
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
/>
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
</HStack>
) : (
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>
{message.form.status === 'done'
? '✓ Created'
: message.form.status === 'cancelled'
? 'Cancelled — nothing was sent.'
: message.form.status === 'failed'
? message.form.error
: 'Expired — ask again to recreate this.'}
</Text>
)}
</VStack>
)}
{message.downloadRows?.length > 0 && (
<VStack className="dai-action" gap={1} padding={0}>
<Button
label={`Download the ${message.downloadRows.length} row${message.downloadRows.length === 1 ? '' : 's'} that didn’t go through`}
variant="secondary"
size="sm"
onClick={() => downloadCsv('doormile-bulk-orders-unsendable.csv', failedRowsCsv(message.downloadRows))}
/>
</VStack>
)}
{message.followUps?.length > 0 && (
<VStack gap={1} padding={0}>
{message.followUps.map((q) => (
@@ -138,14 +197,24 @@ AssistantMessage.propTypes = {
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired
onCancelAction: PropTypes.func.isRequired,
onChooseStep: PropTypes.func.isRequired,
onStopLive: PropTypes.func.isRequired
};
const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction }) =>
const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
message.sender === 'user' ? (
<UserMessage text={message.text} />
) : (
<AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} onSubmitForm={onSubmitForm} onCancelAction={onCancelAction} />
<AssistantMessage
message={message}
onCopy={onCopy}
onAsk={onAsk}
onSubmitForm={onSubmitForm}
onCancelAction={onCancelAction}
onChooseStep={onChooseStep}
onStopLive={onStopLive}
/>
);
AIMessage.propTypes = {
@@ -153,7 +222,14 @@ AIMessage.propTypes = {
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired
onCancelAction: PropTypes.func.isRequired,
onChooseStep: PropTypes.func.isRequired,
onStopLive: PropTypes.func.isRequired
};
export default AIMessage;
// Memoised, and this is not an optimisation garnish — it's what makes the
// thread scrollable. Every scroll event and every composer keystroke re-renders
// the panel, and without this each one re-rendered all 50 restored turns,
// tool-call disclosures included. The panel keeps its handler identities stable
// so this comparison actually holds.
export default memo(AIMessage);

View File

@@ -1,276 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor, loadOrderTenants, loadPickupLocations, validateOrderDraft } from '../orderActions';
// ==============================|| Doormile AI — create-order form ||============================== //
//
// Pickup is a single dropdown, not six fields: `pickuplocationid` names a
// stored tenant location and the server fills address, pincode and coordinates
// from it. That also sidesteps CityGate, since a saved location has already
// passed it.
//
// Delivery still needs a real address, and specifically real coordinates —
// the dispatch optimiser routes on them. The form therefore refuses to submit
// until an address has been PICKED from the suggestions, because typing text
// into the box yields no lat/lng and would create a booking that can never be
// dispatched.
const EMPTY = {
tenantid: '',
pickuplocationid: '',
customer_name: '',
customer_phone: '',
deliveryaddress: '',
deliverypincode: '',
deliverycity: '',
deliverylatitude: undefined,
deliverylongitude: undefined,
service_option: 'Normal',
finalprice: '',
itemcategory: 'General',
itemdescription: '',
declaredvalue: '',
quantity: '1',
notes: ''
};
const fromPlace = (place) => {
const out = {
deliveryaddress: place.formatted_address || '',
deliverylatitude: place.geometry?.location?.lat?.(),
deliverylongitude: place.geometry?.location?.lng?.()
};
(place.address_components || []).forEach((c) => {
const types = c.types || [];
if (types.includes('locality')) out.deliverycity = c.long_name;
if (types.includes('postal_code')) out.deliverypincode = c.long_name;
});
return out;
};
const AIOrderForm = ({ status, error, onSubmit, onCancel }) => {
const [values, setValues] = useState(EMPTY);
const [tenants, setTenants] = useState([]);
const [locations, setLocations] = useState([]);
const [loadingLocations, setLoadingLocations] = useState(false);
const [touched, setTouched] = useState(false);
const set = (key) => (v) => setValues((prev) => ({ ...prev, [key]: v }));
const isBusy = status === 'running';
const isDone = status === 'done';
const isLocked = isDone || status === 'cancelled' || status === 'stale';
useEffect(() => {
let alive = true;
loadOrderTenants().then((list) => alive && setTenants(list));
return () => {
alive = false;
};
}, []);
// Pickup locations are tenant-scoped; a location belonging to another tenant
// is rejected server-side, so the list is reloaded whenever the tenant changes
// and the previous choice is cleared rather than carried over.
useEffect(() => {
if (!values.tenantid) {
setLocations([]);
return undefined;
}
let alive = true;
setLoadingLocations(true);
loadPickupLocations(values.tenantid)
.then((list) => alive && setLocations(list || []))
.finally(() => alive && setLoadingLocations(false));
return () => {
alive = false;
};
}, [values.tenantid]);
const { ok, errors } = validateOrderDraft(values);
const pickup = useMemo(
() => locations.find((l) => String(l.locationid) === String(values.pickuplocationid)),
[locations, values.pickuplocationid]
);
const pickupCity = pickup ? cityGateFor(pickup.pincode) : null;
if (isLocked) {
return (
<VStack className="dai-action" gap={1} padding={0}>
<Text className="dai-action-summary">
{values.customer_name || 'Order'} · {values.customer_phone}
</Text>
<Text className={`dai-action-note${isDone ? ' dai-ok' : ''}`}>
{isDone ? '✓ Created' : status === 'cancelled' ? 'Cancelled — nothing was sent.' : 'Expired — ask again to recreate this.'}
</Text>
</VStack>
);
}
const err = (key) => touched && errors[key] && <Text className="dai-field-err">{errors[key]}</Text>;
return (
<VStack className="dai-action dai-form" gap={2} padding={0}>
<Selector
label="Tenant"
placeholder="Choose a tenant"
options={tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }))}
value={String(values.tenantid || '')}
onChange={(v) => setValues((prev) => ({ ...prev, tenantid: v, pickuplocationid: '' }))}
isDisabled={isBusy}
/>
{err('tenantid')}
<Selector
label="Pickup location"
placeholder={values.tenantid ? 'Choose a pickup location' : 'Choose a tenant first'}
options={locations.map((l) => ({
value: String(l.locationid),
label: `${l.locationname || 'Location'}${l.pincode ? ` · ${l.pincode}` : ''}`
}))}
value={String(values.pickuplocationid || '')}
onChange={set('pickuplocationid')}
isLoading={loadingLocations}
isDisabled={isBusy || !values.tenantid}
/>
{err('pickuplocationid')}
{pickup && (
<Text className="dai-action-note">
{pickup.address}
{pickupCity ? ` · ${pickupCity}` : ' · pincode outside the open cities — this may be refused'}
</Text>
)}
{values.tenantid && !loadingLocations && locations.length === 0 && (
<Text className="dai-field-err">This tenant has no saved pickup locations. Add one on the Tenants page first.</Text>
)}
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput
label="Customer name"
size="sm"
value={values.customer_name}
onChange={set('customer_name')}
placeholder="Ramesh"
isDisabled={isBusy}
/>
<TextInput
label="Mobile"
size="sm"
value={values.customer_phone}
onChange={set('customer_phone')}
placeholder="9876543210"
isDisabled={isBusy}
/>
</HStack>
{err('customer_name')}
{err('customer_phone')}
<AddressAutocomplete
label="Delivery address"
placeholder="Search the delivery address"
value={values.deliveryaddress}
onChange={set('deliveryaddress')}
onPlaceSelected={(place) => setValues((prev) => ({ ...prev, ...fromPlace(place) }))}
disabled={isBusy}
/>
{err('deliveryaddress')}
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput
label="Delivery pincode"
size="sm"
value={values.deliverypincode}
onChange={set('deliverypincode')}
placeholder="641012"
isDisabled={isBusy}
/>
<TextInput
label="City"
size="sm"
value={values.deliverycity}
onChange={set('deliverycity')}
placeholder="Coimbatore"
isDisabled={isBusy}
/>
</HStack>
{err('deliverypincode')}
<HStack className="dai-form-row" gap={0} padding={0}>
<Selector
label="Service"
options={SERVICE_OPTIONS.map((o) => ({ value: o, label: o }))}
value={values.service_option}
onChange={set('service_option')}
isDisabled={isBusy}
/>
<TextInput label="Price" size="sm" value={values.finalprice} onChange={set('finalprice')} placeholder="120" isDisabled={isBusy} />
</HStack>
{err('finalprice')}
<TextInput
label="Parcel description"
size="sm"
value={values.itemdescription}
onChange={set('itemdescription')}
placeholder="2 meal boxes"
isDisabled={isBusy}
/>
{err('itemdescription')}
<HStack className="dai-form-row" gap={0} padding={0}>
<TextInput
label="Declared value"
size="sm"
value={values.declaredvalue}
onChange={set('declaredvalue')}
placeholder="350"
isDisabled={isBusy}
/>
<TextInput label="Quantity" size="sm" value={values.quantity} onChange={set('quantity')} placeholder="1" isDisabled={isBusy} />
<TextInput label="Notes" size="sm" value={values.notes} onChange={set('notes')} placeholder="Ring the bell" isDisabled={isBusy} />
</HStack>
<Text className="dai-action-note">
Pickup address, pincode and coordinates come from the saved location. Delivery coordinates come from the address search — pick a
suggestion rather than typing, or the order can’t be routed.
</Text>
{status === 'failed' && error && <Text className="dai-field-err">{error}</Text>}
<HStack gap={1.5} padding={0}>
<Button
label={isBusy ? 'Creating…' : 'Create order'}
variant="primary"
size="sm"
isDisabled={!ok || isBusy}
onClick={() => {
setTouched(true);
// __pickup carries the saved location itself, not just its id —
// the payload copies its address/pincode/coords onto the booking.
if (ok) onSubmit({ ...values, __pickup: pickup });
}}
/>
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
</HStack>
{!ok && touched && <Text className="dai-field-err">Fill in the highlighted fields before creating.</Text>}
</VStack>
);
};
AIOrderForm.propTypes = {
status: PropTypes.string,
error: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired
};
export default AIOrderForm;

View File

@@ -16,7 +16,11 @@ import { OpenToast } from 'components/third-party/OpenToast';
import { STATUS } from 'themes/dt/tokens';
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents';
import { executeCreateCustomer, buildCustomerPayload } from '../actions';
import { executeCreateOrder, buildOrderPayload } from '../orderActions';
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '../orderActions';
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 { getPageContext } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome';
@@ -53,7 +57,10 @@ const loadHistory = () => {
// last closed is retired on load. Otherwise a Create button could sit in
// restored history for days and fire against data the operator can no
// longer see — the proposal must be regenerated against current state.
return parsed.map((m) => (m.form?.status === 'open' ? { ...m, form: { ...m.form, status: 'stale' } } : m));
return parsed.map((m) => {
const next = m.flowStep ? { ...m, flowStep: undefined } : m;
return next.form?.status === 'open' ? { ...next, form: { ...next.form, status: 'stale' } } : next;
});
} catch {
return [];
}
@@ -78,10 +85,25 @@ const AIPanel = ({ isOpen, onClose }) => {
const [isSending, setIsSending] = useState(false);
const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
const [isPinned, setIsPinned] = useState(true);
// An in-progress conversational create. Held in memory only — never
// persisted, so a half-finished flow can't be resurrected in a later session
// and submitted against stale intent.
const [flow, setFlow] = useState(null);
const panelRef = useRef(null);
const scrollRef = useRef(null);
const lastQuestionRef = useRef(null);
// Mirrors isPinned so the throttled scroll handler can compare without
// re-subscribing, and cancels an in-flight frame on unmount.
const pinnedRef = useRef(true);
// Stop is a ref, never state: read inside the async pass, a state value is
// captured at call time and never updates, so Stop did nothing. The geocode
// cache is a ref so re-running after a fix doesn't re-look-up what's known.
const bulkStop = useRef(false);
const bulkCache = useRef(new Map());
const rafRef = useRef(0);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
// ---- open / close transition ----
useEffect(() => {
@@ -126,19 +148,209 @@ const AIPanel = ({ isOpen, onClose }) => {
if (el) el.scrollTop = el.scrollHeight;
}, [messages, isSending, isPinned]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
setIsPinned(el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX);
};
// A scroll event fires several times per frame, and this used to call
// setIsPinned on every one of them — re-rendering the entire thread mid-scroll.
// The measurement is now rAF-throttled and the state is written only when the
// flag actually flips, so ordinary scrolling costs no renders at all.
const handleScroll = useCallback(() => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
const el = scrollRef.current;
if (!el) return;
const pinned = el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
if (pinned === pinnedRef.current) return;
pinnedRef.current = pinned;
setIsPinned(pinned);
});
}, []);
const scrollToLatest = () => {
const scrollToLatest = useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
pinnedRef.current = true;
setIsPinned(true);
};
}, []);
const push = (message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]);
// Every handler passed to AIMessage is stabilised, because a new function
// identity on each render defeats its memo and puts all 50 turns back on the
// critical path of a scroll.
const push = useCallback((message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]), []);
// Pushes a message and hands back its id, so a long-running pass can rewrite
// that one turn rather than pushing a new one per row.
const pushLive = useCallback((message) => {
const id = makeId();
setMessages((prev) => [...prev, { id, timestamp: now(), ...message }]);
return id;
}, []);
const patch = useCallback((id, fields) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, ...fields } : m))), []);
// Renders whatever a flow step needs: a dropdown, a typed question, or the
// finished quote. Kept in one place so the start of a flow and every
// subsequent answer produce identical-looking turns.
// Renders one turn of ANY conversational create. The question half is shared;
// what happens at the end is the caller's (finishOrder / finishBulk).
const renderFlowStep = useCallback(
async (next, onDone) => {
if (!next.done) {
if (next.retry) {
push({ sender: 'assistant', text: next.ask });
return;
}
const step = next.step;
push({
sender: 'assistant',
text: next.ask || step.ask,
// A `select` or `rows` step carries the step definition so AIMessage
// can render its control; a `text` step is answered in the composer.
flowStep: step.type === 'select' || step.type === 'rows' ? step : undefined,
flowDraft: next.flow.draft
});
return;
}
await onDone(next.draft);
},
[push]
);
// The single order's completion: quote, gate, then offer one press.
const finishOrder = useCallback(
async (draft) => {
// Every field gathered, price included — the flow itself quotes it and asks
// for the amount when it can't (see orderFlow.js's `finalprice` step), so
// there is never a NaN or an invented zero on the wire.
const quote = draft.__quote || {};
// Last gate before a Create button exists. The flow validates field by
// field, but this is the same check the Create Order page runs on the whole
// record — if it fails, the operator gets told what's missing instead of a
// button that would post a rejected booking.
const { ok, errors } = validateOrderDraft(draft);
if (!ok) {
push({
sender: 'assistant',
state: {
tone: STATUS.error,
iconKey: 'warning',
title: "This order isn't complete",
body: `${Object.values(errors).join(' · ')}. Say “create an order” to start again.`
}
});
setFlow(null);
return;
}
push({
sender: 'assistant',
text: `That comes to ₹${Number(draft.finalprice).toFixed(2)}.`,
list: {
title: 'Will be sent',
numbered: false,
items: [
{ label: 'Customer', meta: `${draft.customer_name} · ${draft.customer_phone}` },
{ label: 'Pickup', meta: draft.__pickup?.locationname || draft.__pickup?.address || '—' },
{ label: 'Drop', meta: draft.deliveryaddress },
{ label: 'Service', meta: draft.service_option },
{ label: 'Parcels', meta: `${draft.quantity} · ${draft.itemcategory} · ${draft.itemdescription}` },
quote.km != null
? { label: 'Distance', meta: `${quote.km} km${quote.durationMin ? ` · ${quote.durationMin} min` : ''}` }
: null,
{ label: 'Price', meta: `₹${Number(draft.finalprice).toFixed(2)}` }
].filter(Boolean)
},
// Where the number came from matters: a quoted price is reproducible from
// the tenant's pricing row, an entered one is the operator's own.
detail:
quote.total != null
? 'Priced from this tenant’s pricing row and the routed distance.'
: 'Price entered manually — it couldn’t be quoted automatically.',
form: { kind: 'confirmOrder', status: 'open', draft }
});
},
[push]
);
// 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.
const finishBulk = useCallback(
async (draft) => {
const rows = draft.rows;
const needed = lookupsNeeded(rows, bulkCache.current);
bulkStop.current = false;
const liveId = pushLive({
sender: 'assistant',
text: needed
? `Locating ${needed} address${needed === 1 ? '' : 'es'} — about ${Math.ceil(
(needed * GEOCODE_INTERVAL_MS) / 1000
)}s. The address service allows roughly one per second.`
: 'Checking the rows…',
// Rendered as a plain turn with a Stop button; see AIMessage's liveStop.
liveStop: true
});
const result = await resolveBulkRows(rows, {
pickup: draft.__pickup,
tenantid: draft.tenantid || localStorage.getItem('tenantid'),
cache: bulkCache.current,
shouldStop: () => bulkStop.current,
onProgress: (p) =>
patch(liveId, {
text: `${p.phase === 'price' ? 'Pricing' : 'Locating addresses'}… ${Math.min(p.done + 1, p.total)} of ${p.total}`,
detail: p.current || undefined
})
});
patch(liveId, { text: `Checked ${rows.length} row${rows.length === 1 ? '' : 's'}.`, detail: undefined, liveStop: false });
if (!result.valid.length) {
push({
sender: 'assistant',
state: {
tone: STATUS.error,
iconKey: 'warning',
title: 'None of these rows can be sent',
body: result.invalid[0]?.error
? `The first problem is: ${result.invalid[0].error}. Fix the rows and say “bulk upload orders” again.`
: 'Fix the rows and try again.'
}
});
setFlow(null);
return;
}
const batches = batchCount(result.valid.length);
push({
sender: 'assistant',
text: `${result.valid.length} order${result.valid.length === 1 ? '' : 's'} ready to create.`,
list: {
title: 'Will be sent',
numbered: false,
// Capped for readability, and the cap is STATED — a list that silently
// showed 12 of 200 would read as the whole file.
items: result.valid.slice(0, 12).map((r) => ({
label: `${r.customer_name} · ${r.customer_phone}`,
meta: `₹${Number(r.finalprice).toFixed(2)}${r.quoted ? ' quoted' : ''} · ${r.resolvedAddress || r.deliveryaddress}`
}))
},
detail: [
result.valid.length > 12 ? `Showing the first 12 of ${result.valid.length}.` : null,
result.invalid.length
? `${result.invalid.length} row${result.invalid.length === 1 ? '' : 's'} cannot be sent and will be left out.`
: null,
batches > 1 ? `Sent in ${batches} batches of at most 200.` : null,
`Pickup: ${draft.__pickup?.locationname || draft.__pickup?.address || '—'} · ${draft.service_option}`
]
.filter(Boolean)
.join(' '),
form: { kind: 'confirmBulk', status: 'open', draft: { ...draft, rows: result.valid, invalid: result.invalid } }
});
},
[push, pushLive, patch]
);
const ask = useCallback(
async (text) => {
@@ -152,14 +364,111 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(true);
try {
// ---- an answer to a question we asked ----------------------------
//
// Checked BEFORE the router. This is the whole fix: answerQuestion
// picks an intent by matching text, so a bare reply like
// "8494948494" matches nothing and used to be swallowed by the
// "I can't answer that yet" fallback. Mid-flow, the text is an
// answer — not a question — and is never routed.
if (flow && !flow.complete) {
if (/^(?:cancel|stop|nevermind|never mind|forget it)$/i.test(question)) {
setFlow(null);
push({ sender: 'assistant', text: 'Cancelled — nothing was created.' });
setIsSending(false);
return;
}
// An order flow answers through this same path when the step is
// free text; `select` steps come in through chooseStep instead.
if (flow.kind === 'createBulk') {
const next = await answerBulkStep(flow, question);
setFlow(next.flow);
await renderFlowStep(next, finishBulk);
setIsSending(false);
return;
}
if (flow.kind === 'createOrder') {
const next = await answerOrderStep(flow, question);
setFlow(next.flow);
await renderFlowStep(next, finishOrder);
setIsSending(false);
return;
}
const next = answerStep(flow, question);
setFlow(next.flow);
if (next.done) {
// Every field gathered. Show exactly what will be sent and wait
// for Submit — the same write gate every other create uses.
push({
sender: 'assistant',
text: 'Ready to create this customer.',
list: {
title: 'Will be sent',
numbered: false,
items: Object.entries(next.payload).map(([k, v]) => ({ label: k, meta: String(v) }))
},
form: { kind: 'confirmCustomer', status: 'open', payload: next.payload }
});
} else {
push({ sender: 'assistant', text: next.ask });
}
setIsSending(false);
return;
}
const result = await answerQuestion(question, context);
if (result) {
setContext({ lastIntentId: result.intentId, lastParams: result.params });
// createCustomer no longer opens a form — it starts a conversation.
// 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 === 'createBulkOrders') {
const started = await startBulkFlow();
setFlow(started.flow);
await renderFlowStep(started, finishBulk);
setIsSending(false);
return;
}
if (result.form?.kind === 'createOrder') {
const started = await startOrderFlow();
setFlow(started.flow);
await renderFlowStep(started, finishOrder);
setIsSending(false);
return;
}
if (result.form?.kind === 'createCustomer') {
const started = startCustomerFlow(question);
setFlow(started.flow);
push({ sender: 'assistant', text: started.done ? 'Ready to create this customer.' : started.ask });
if (started.done) {
push({
sender: 'assistant',
list: {
title: 'Will be sent',
numbered: false,
items: Object.entries(started.payload).map(([k, v]) => ({ label: k, meta: String(v) }))
},
form: { kind: 'confirmCustomer', status: 'open', payload: started.payload }
});
}
setIsSending(false);
return;
}
push({
form: result.form,
sender: 'assistant',
text: result.headline,
detail: result.detail,
// Documentation answers read as prose, not as a figure.
isDoc: result.intentId === 'docsAnswer',
metric: result.metric,
stats: result.stats,
sourceCalls: result.sourceCalls,
@@ -168,13 +477,18 @@ const AIPanel = ({ isOpen, onClose }) => {
} else {
// Matched nothing. This is a coverage state, not a failure — say so
// plainly rather than inventing an answer.
// Genuinely off-topic questions land here. Saying only "I can't
// answer that" leaves an operator with nowhere to go, so this points
// at the two real places an answer might exist: rephrasing around
// the data this console holds, or doormile.com for anything about
// Doormile itself, which this app has no material on.
push({
sender: 'assistant',
state: {
tone: STATUS.muted,
iconKey: 'info',
title: "I can't answer that one yet",
body: 'Try rephrasing around orders, riders, hubs, vehicles, batches, status, tenants or revenue — or pick one of the suggestions.'
title: "I can't answer that one",
body: 'I only read this console’s live operations — orders, riders, hubs, vehicles, batches, tenants and revenue. Try rephrasing around those, or see doormile.com for anything about Doormile itself.'
}
});
}
@@ -194,105 +508,244 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(false);
}
},
[context, isSending]
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, push]
);
const copyMessage = (m) => {
const copyMessage = useCallback((m) => {
const text = [m.text, m.detail].filter(Boolean).join('\n');
navigator.clipboard
.writeText(text)
.then(() => OpenToast('Copied to clipboard', 'success', 1500))
.catch(() => OpenToast('Could not copy', 'error', 1500));
};
}, []);
const clearConversation = () => {
const clearConversation = useCallback(() => {
setMessages([]);
setContext({ lastIntentId: null, lastParams: null });
setFlow(null);
pinnedRef.current = true;
setIsPinned(true);
};
}, []);
const setFormStatus = (id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m)));
const setFormStatus = useCallback(
(id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m))),
[]
);
// The single place a write leaves the assistant. Reached only by pressing
// Create on an open form.
const submitForm = async (message, values) => {
if (message.form?.status !== 'open') return;
setFormStatus(message.id, { status: 'running', error: undefined, initial: values });
const submitForm = useCallback(
async (message, values) => {
if (message.form?.status !== 'open') return;
setFormStatus(message.id, { status: 'running', error: undefined, initial: values });
// ---- order ----
if (message.form.kind === 'createOrder') {
// The saved location the operator chose — its address/pincode/coords go
// on the booking, because pickuplocationid 500s server-side.
const payload = buildOrderPayload(values, values.__pickup);
const res = await executeCreateOrder(payload);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
// ---- conversational order: submit what the chat gathered -------------
if (message.form.kind === 'confirmOrder') {
const draft = message.form.draft;
const payload = buildOrderPayload(draft, draft.__pickup);
const res = await executeCreateOrder(payload);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
setFlow(null);
push({
sender: 'assistant',
text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
detail: 'It appears on the Orders page under Pending until a rider is assigned.',
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message },
sourceCalls: res.sourceCalls
});
}
return;
}
// ---- conversational customer: submit what the chat gathered ----------
if (message.form.kind === 'confirmCustomer') {
try {
const res = await executeCreateCustomer(message.form.payload);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
setFlow(null);
push({
sender: 'assistant',
text: `Created ${[message.form.payload.firstname, message.form.payload.lastname].filter(Boolean).join(' ')}.`,
// Naming the store is worth keeping: two customer resources exist,
// POST /admin/customers is a confirmed 405, and the Customers page
// now reads this one — so the record does show up.
detail: `Customer ID ${res.id ?? '—'}. Saved to /admin/tenantcustomers — it appears on the Customers page.`,
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message },
sourceCalls: res.sourceCalls
});
}
} catch (err) {
console.error('[Doormile AI] confirmCustomer failed', err);
setFormStatus(message.id, { status: 'failed', error: err.message || 'Nothing was saved.' });
}
return;
}
// ---- bulk orders ----
if (message.form.kind === 'confirmBulk') {
const { rows, invalid, ...shared } = message.form.draft;
const res = await executeCreateBulk(rows, shared);
// A bulk import is normally a PARTIAL result, so the outcome is reported
// per row rather than as a single success/failure. Without that, the only
// safe response to any error is to re-submit everything — which
// double-books whatever did land.
setFormStatus(message.id, {
status: res.failed && !res.created ? 'failed' : 'done',
error: res.failed ? `${res.failed} row(s) failed` : undefined
});
push({
sender: 'assistant',
text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
list: {
title: 'Submitted',
numbered: false,
items: Object.entries(payload)
.filter(([k]) => k !== 'parcels')
.map(([k, v]) => ({ label: k, meta: String(v) }))
.concat([{ label: 'parcels', meta: `${payload.parcels.length} · ${payload.parcels[0].itemdescription}` }])
},
detail: 'Saved to /admin/expressbooking. It will appear on the Orders page under Pending until a rider is assigned.',
text: `Created ${res.created} of ${rows.length} order${rows.length === 1 ? '' : 's'}.`,
list: res.failures.length
? {
title: 'Not created',
numbered: false,
items: res.failures.map((f) => ({ label: rows[f.index]?.customer_name || `Row ${f.index + 1}`, meta: f.reason }))
}
: undefined,
detail:
res.failed === 0
? `All rows submitted${res.batches > 1 ? ` in ${res.batches} batches` : ''}. They appear on the Orders page under Pending.`
: `${res.created} landed and ${res.failed} did not. Only the failed rows need re-submitting — re-sending everything would duplicate the ones that worked.`,
// Rejected rows plus anything the check had already set aside, in the
// template's own columns, so they can be fixed and re-uploaded
// instead of re-sending a file that would double-book what landed.
downloadRows: [...res.failures.map((f) => ({ ...rows[f.index], error: f.reason })), ...(invalid || [])],
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message },
sourceCalls: res.sourceCalls
setFlow(null);
return;
}
// ---- order ----
if (message.form.kind === 'createOrder') {
// The saved location the operator chose — its address/pincode/coords go
// on the booking, because pickuplocationid 500s server-side.
const payload = buildOrderPayload(values, values.__pickup);
const res = await executeCreateOrder(payload);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
push({
sender: 'assistant',
text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
list: {
title: 'Submitted',
numbered: false,
items: Object.entries(payload)
.filter(([k]) => k !== 'parcels')
.map(([k, v]) => ({ label: k, meta: String(v) }))
.concat([{ label: 'parcels', meta: `${payload.parcels.length} · ${payload.parcels[0].itemdescription}` }])
},
detail: 'Saved to /admin/expressbooking. It will appear on the Orders page under Pending until a rider is assigned.',
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message },
sourceCalls: res.sourceCalls
});
}
return;
}
try {
const payload = buildCustomerPayload(values);
const res = await executeCreateCustomer(payload);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
push({
sender: 'assistant',
text: `Created ${[payload.firstname, payload.lastname].filter(Boolean).join(' ')}.`,
// Everything that was actually sent, so the operator can see the
// whole record instead of a one-line summary.
list: {
title: 'Saved',
numbered: false,
items: Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }))
},
// Same store as the conversational path above.
detail: `Customer ID ${res.id ?? '—'}. Saved to /admin/tenantcustomers — it appears on the Customers page.`,
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message },
sourceCalls: res.sourceCalls
});
}
} catch (err) {
console.error('[Doormile AI] createCustomer failed', err);
setFormStatus(message.id, {
status: 'failed',
// executeCreateCustomer reports HTTP failures itself with the status and
// endpoint; anything reaching here is unexpected.
error: err.response?.data?.message || `${err.message || 'Unexpected error'} — nothing was saved.`
});
}
return;
}
},
[push, setFormStatus]
);
try {
const payload = buildCustomerPayload(values);
const res = await executeCreateCustomer(payload);
if (res.ok) {
setFormStatus(message.id, { status: 'done' });
push({
sender: 'assistant',
text: `Created ${[payload.firstname, payload.lastname].filter(Boolean).join(' ')}.`,
// Everything that was actually sent, so the operator can see the
// whole record instead of a one-line summary.
list: {
title: 'Saved',
numbered: false,
items: Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }))
},
detail: `Customer ID ${res.id ?? '—'}.\nSaved to /admin/customers — it should now appear on the Customers page.`,
sourceCalls: res.sourceCalls
});
} else {
setFormStatus(message.id, { status: 'failed', error: res.message });
push({
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message },
sourceCalls: res.sourceCalls
});
// A dropdown selection. Same code path as a typed answer — the only
// difference is that the chosen OPTION comes along too, because steps that
// pick a record (a location, a customer) fill several fields from it.
const chooseStep = useCallback(
async (message, value, option) => {
if (!flow || flow.complete) return;
// Mark the rendered dropdown as spent so it can't be submitted twice while
// the next step is loading.
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);
setFlow(next.flow);
await renderFlowStep(next, isBulk ? finishBulk : finishOrder);
} 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.' });
} finally {
setIsSending(false);
}
} catch (err) {
console.error('[Doormile AI] createCustomer failed', err);
setFormStatus(message.id, {
status: 'failed',
// executeCreateCustomer reports HTTP failures itself with the status and
// endpoint; anything reaching here is unexpected.
error: err.response?.data?.message || `${err.message || 'Unexpected error'} — nothing was saved.`
});
}
};
},
[flow, push, renderFlowStep, finishBulk, finishOrder]
);
const cancelAction = (message) => {
if (message.form?.status !== 'open') return;
setFormStatus(message.id, { status: 'cancelled' });
};
// Stops the long locate/price pass. The flag is a ref for the reason
// documented on it — as state it was captured at call time and never seen.
const stopLive = useCallback(
(message) => {
bulkStop.current = true;
patch(message.id, { text: 'Stopping — the rows already checked are kept.', liveStop: false });
},
[patch]
);
const cancelAction = useCallback(
(message) => {
if (message.form?.status !== 'open') return;
setFormStatus(message.id, { status: 'cancelled' });
},
[setFormStatus]
);
if (!isMounted) return null;
@@ -361,6 +814,8 @@ const AIPanel = ({ isOpen, onClose }) => {
onAsk={ask}
onSubmitForm={submitForm}
onCancelAction={cancelAction}
onChooseStep={chooseStep}
onStopLive={stopLive}
/>
))}
{isSending && (

View File

@@ -1,20 +1,25 @@
import PropTypes from 'prop-types';
import { LuSparkles } from 'react-icons/lu';
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import doormileMark from 'assets/images/doormile-mark.png';
// ==============================|| Doormile AI — shared primitives ||============================== //
//
// Small presentational pieces shared by the panel's states. Kept in one file
// because each is a handful of lines and they are only ever used together.
// The AI mark — a soft gradient orb, deliberately not a robot face.
// The Doormile D. One component behind the assistant's identity, so the
// header, every reply, the welcome screen and the thinking state can't drift
// apart — they all render this. The mark is RGBA with a transparent ground, so
// it sits on the panel surface rather than needing a coloured chip behind it.
export const Spark = ({ size = 'sm' }) => (
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
<LuSparkles size={size === 'lg' ? 20 : size === 'md' ? 15 : 12} strokeWidth={2.1} />
<img src={doormileMark} alt="" />
</HStack>
);

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { TextArea } from '@astryxdesign/core/TextArea';
import { FileInput } from '@astryxdesign/core/FileInput';
import { parseBulkRows, BULK_MAX } from '../bulkOrderActions';
import { parseBulkFile, templateCsv, downloadCsv } from '../bulkFile';
// ==============================|| Doormile AI — the rows turn ||============================== //
//
// One conversational turn that accepts a whole file. A sheet and a paste both
// produce the same row array (bulkFile.js / parseBulkRows), so this hands back
// one parse result either way and the flow doesn't care which was used.
//
// File and paste are offered together rather than behind a "file or paste?"
// question, because that question costs a turn and answers nothing the operator
// hasn't already decided by having a file or not.
//
// What is reported before the operator can continue, and why each matters:
// • rows read — so a truncated or half-empty sheet is visible immediately
// • columns NOT recognised, by name — an unread price column would otherwise
// silently re-quote every row
// • rows repeated inside the file — the bulk endpoint has no idempotency key,
// so a duplicate that gets through is a second real rider at one door
const PLACEHOLDER = `Ramesh Kumar, 9876543210, 12 Cross Cut Rd Gandhipuram, 641012, Coimbatore, 120, 2 meal boxes
Priya N, 9876500011, 45 RS Puram, 641002, Coimbatore, 95, documents`;
const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => {
const [file, setFile] = useState(null);
const [fileParse, setFileParse] = useState(null);
const [fileError, setFileError] = useState(null);
const [raw, setRaw] = useState('');
const [isReading, setIsReading] = useState(false);
const pasted = raw.trim() ? parseBulkRows(raw) : null;
// A loaded file wins; clearing it falls back to whatever is in the textarea.
const parsed = fileParse || pasted;
const handleFile = async (picked) => {
const chosen = Array.isArray(picked) ? picked[0] : picked;
setFile(chosen || null);
setFileError(null);
if (!chosen) {
setFileParse(null);
return;
}
setIsReading(true);
try {
const result = await parseBulkFile(chosen);
setFileParse(result);
setRaw('');
} catch (err) {
setFileParse(null);
setFileError(err.message || 'Couldn’t read that file.');
} finally {
setIsReading(false);
}
};
return (
<VStack className="dai-action" gap={1.5} padding={0}>
<FileInput
label="Order sheet"
isLabelHidden
accept=".csv,.xls,.xlsx"
value={file}
onChange={handleFile}
isDisabled={isBusy || isReading}
/>
{!fileParse && (
<TextArea label="Or paste rows" rows={4} value={raw} onChange={setRaw} placeholder={PLACEHOLDER} isDisabled={isBusy} />
)}
{isReading && <Text className="dai-action-note">Reading the file…</Text>}
{fileError && <Text className="dai-field-err">{fileError}</Text>}
{parsed && (
<VStack gap={0.5} padding={0}>
<Text className="dai-action-note">
{fileParse ? `${fileParse.sheetName} — ` : ''}
{parsed.rows.length} complete row{parsed.rows.length === 1 ? '' : 's'}
{parsed.errors.length ? ` · ${parsed.errors.length} unusable` : ''}
</Text>
{parsed.ignoredColumns?.length > 0 && (
<Text className="dai-action-note">Columns I didn’t recognise, so didn’t use: {parsed.ignoredColumns.join(', ')}</Text>
)}
{parsed.duplicates?.length > 0 && (
<Text className="dai-field-err">
{parsed.duplicates.length} row{parsed.duplicates.length === 1 ? '' : 's'} repeat an earlier phone and address (
{parsed.duplicates.map((d) => `line ${d.line}`).join(', ')}) — they’d become separate orders.
</Text>
)}
{parsed.errors.slice(0, 5).map((e) => (
<Text key={e.line} className="dai-field-err">
Line {e.line}: {e.reason}
</Text>
))}
{parsed.errors.length > 5 && <Text className="dai-action-note">…and {parsed.errors.length - 5} more unusable rows.</Text>}
{parsed.rows.length > BULK_MAX && (
<Text className="dai-action-note">
Over {BULK_MAX} — I’ll send them in {Math.ceil(parsed.rows.length / BULK_MAX)} batches.
</Text>
)}
</VStack>
)}
<HStack gap={1.5} padding={0}>
<Button
label={parsed?.rows.length ? `Use these ${parsed.rows.length} rows` : 'Continue'}
variant="primary"
size="sm"
isDisabled={!parsed?.rows.length || isBusy || isReading}
onClick={() => onSubmit(parsed)}
/>
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
<Button
label="Template"
variant="ghost"
size="sm"
tooltip="Download a blank sheet with the exact column titles I read"
onClick={() => downloadCsv('doormile-bulk-orders-template.csv', templateCsv())}
/>
</HStack>
<Text className="dai-action-note">
Pasted rows: name, phone, address, pincode, city, price, description — comma, tab or pipe separated. Leave the price blank and I’ll
quote it.
</Text>
</VStack>
);
};
AIRowsStep.propTypes = {
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
isBusy: PropTypes.bool
};
export default AIRowsStep;

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { LuArrowRight } from 'react-icons/lu';
@@ -41,9 +40,10 @@ SuggestionCard.propTypes = {
};
const AIWelcome = ({ context, onAsk }) => {
const [expanded, setExpanded] = useState(false);
const more = context.more || [];
const shown = expanded ? [...context.suggestions, ...more] : context.suggestions;
// Every suggestion for this page, always. `context.more` used to sit behind a
// "View N more" / "Show fewer" toggle; hiding half the things the assistant
// can answer behind a click made it look narrower than it is.
const shown = [...context.suggestions, ...(context.more || [])];
return (
<VStack className="dai-welcome" gap={3} padding={0}>
@@ -65,11 +65,6 @@ const AIWelcome = ({ context, onAsk }) => {
<SuggestionCard key={item.text} item={item} onAsk={onAsk} />
))}
</VStack>
{more.length > 0 && (
<button type="button" className="dai-link" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded}>
{expanded ? 'Show fewer' : `View ${more.length} more`}
</button>
)}
</VStack>
</VStack>
);

View File

@@ -1,8 +1,8 @@
import { useCallback, useRef, useState } from 'react';
import { LuSparkles } from 'react-icons/lu';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import doormileMark from 'assets/images/doormile-mark.png';
import AIPanel from './AIPanel';
import '../DoormileAI.css';
@@ -33,7 +33,7 @@ const DoormileAITrigger = () => {
aria-expanded={isOpen}
onClick={() => (isOpen ? close() : setIsOpen(true))}
>
<LuSparkles size={16} strokeWidth={2.1} aria-hidden="true" />
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
</button>
</Tooltip>
<AIPanel isOpen={isOpen} onClose={close} />

View File

@@ -10,7 +10,8 @@ import {
LuLayers,
LuTimerOff,
LuUserPlus,
LuPackagePlus
LuPackagePlus,
LuListPlus
} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
@@ -34,6 +35,7 @@ const ORDERS = {
{ icon: LuPackagePlus, text: 'Create an order' }
],
more: [
{ icon: LuListPlus, text: 'Create multiple orders' },
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuCircleDot, text: 'How many pending orders today?' },
{ icon: LuPackage, text: 'How many cancelled orders today?' },
@@ -159,7 +161,43 @@ const ROUTES = [
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
];
// ---- every question, from every page ---------------------------------------
//
// The catalog is the same wherever you are: the assistant can answer about
// orders, riders, hubs and the rest regardless of which screen is open, so
// hiding a question because you happen to be on Dispatch made it look narrower
// than it is. What the page still decides is ORDER — its own questions lead,
// then everything else follows, deduplicated by text.
//
// `more` is retired: AIWelcome renders one flat list now, so a second array
// would only be a second place for a question to hide.
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
const EVERY_QUESTION = (() => {
const seen = new Set();
const out = [];
ALL_CONTEXTS.forEach((c) => {
[...(c.suggestions || []), ...(c.more || [])].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
out.push(q);
});
});
return out;
})();
const withEveryQuestion = (context) => {
const seen = new Set();
const suggestions = [];
[...(context.suggestions || []), ...(context.more || []), ...EVERY_QUESTION].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
suggestions.push(q);
});
return { ...context, suggestions, more: [] };
};
export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
return match ? match[1] : DEFAULT_CONTEXT;
return withEveryQuestion(match ? match[1] : DEFAULT_CONTEXT);
};

View File

@@ -1,4 +1,4 @@
import { createAdminCustomer } from 'pages/api/doormileApi';
import { createTenantCustomer } from 'pages/api/doormileApi';
// ==============================|| Doormile AI — write actions ||============================== //
//
@@ -102,35 +102,43 @@ export const buildCustomerPayload = (draft) =>
//
// ---- Which endpoint, and why -----------------------------------------------
//
// Writes to POST /admin/customers — the same resource the Customers page
// reads, so a created customer shows up there immediately.
// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
// by reading the docs:
//
// Note for anyone reading express-console-api.md: that document lists
// /admin/customers as GET + PATCH only, and describes a customer there as
// something created by a booking (POST /admin/expressbooking documents
// `customer_phone` as "creates a Guest customer if unknown"). This call was
// pointed here deliberately anyway. If the route turns out not to exist the
// server answers 404/405 and the assistant shows that verbatim alongside the
// endpoint name, so the failure is loud rather than silent.
// POST /admin/customers → 405 Method Not Allowed (confirmed live)
//
// The previous target was POST /admin/tenantcustomers, which succeeded but
// wrote to a different store with its own id sequence — a customer created
// that way came back as id 2 while the Customers list ran to id 44, and was
// invisible on that page. That is the bug this change fixes.
// 405 is the unambiguous answer: the route exists, and POST is not among its
// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
// and the server agrees. It was pointed there briefly on explicit instruction;
// the live 405 settled it.
//
// The payload already mirrors this resource's own GET shape (firstname,
// lastname, phone, email, address, doorno, landmark, suburb, city, state,
// postcode, latitude, longitude), so nothing else had to move.
// The consequence, which the assistant states in its success message rather
// than leaving the operator to discover: a customer created here does NOT
// appear on the Customers page, because that page reads GET /admin/customers.
// On that resource a customer comes into existence as a side effect of a
// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
// Guest customer if unknown". A B2C customer is, by design, someone who has
// ordered.
//
// To make created customers visible on that page, one of these has to happen:
// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
// it changes what that page means, and its edit dialog would then PATCH a
// different store by id), or
// • the backend adds POST /admin/customers.
//
// The payload keeps the address fields. The documented body is
// { firstname, lastname, phone, email }; the rest are sent best-effort and
// ignored if unsupported.
export const executeCreateCustomer = async (payload) => {
const started = Date.now();
const call = {
name: 'createAdminCustomer',
target: 'POST /admin/customers',
name: 'createTenantCustomer',
target: 'POST /admin/tenantcustomers',
stats: Object.keys(payload).join(', ')
};
try {
const res = await createAdminCustomer(payload);
const res = await createTenantCustomer(payload);
const duration = `${Date.now() - started}ms`;
// doormileApi mutations return the full envelope, so a `success: false`
@@ -158,19 +166,22 @@ export const executeCreateCustomer = async (payload) => {
// from "the body was wrong". Report the status, the server's own message,
// and name the endpoint.
const duration = `${Date.now() - started}ms`;
const status = err.response?.status;
const serverMessage = err.response?.data?.message || err.response?.data?.error;
// doormileAxios rejects with the response BODY, not the axios error, so
// `err.response` is undefined here — the status arrives as `err.httpStatus`.
const status = err.httpStatus ?? err.response?.status;
const serverMessage = err.message || err.error;
let message;
if (status === 404 || status === 405) {
// express-console-api.md documents /admin/customers as GET + PATCH only.
// If the server agrees, say so plainly rather than implying the operator
// typed something wrong.
message =
`POST /admin/customers returned ${status} — that route does not exist on the server. ` +
'A customer there is created by a booking, or via POST /admin/tenantcustomers.';
if (status === 405) {
// 405 is "the route exists but not this method" — a different fact from
// 404, and worth stating precisely so nobody re-tries the same call.
message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
} else if (status === 404) {
message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
} else if (status === 400 || status === 422) {
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
} else if (status) {
message = `POST /admin/customers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
} else {
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
}

View File

@@ -0,0 +1,223 @@
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
// ==============================|| Doormile AI — bulk order file upload ||============================== //
//
// Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from
// a paste, so everything downstream — geocoding, validation, review, the chunked
// submit, the per-row outcome report — is untouched by where the rows came from.
//
// Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the
// ones multipleOrders.js uses, as is the column map. A sheet that uploads on
// that page uploads here.
//
// Three reporting rules, all of them about not lying by omission:
//
// • An unparseable row becomes a REPORTED error with its line number, never a
// silently skipped line. A bulk import that quietly drops row 14 is worse
// than one that refuses outright.
// • Columns that were not recognised are NAMED. An operator whose price
// column is titled something unexpected has to be told it was ignored, or
// they'll submit 200 orders priced from a column nothing ever read.
// • Rows duplicated inside the file are flagged BEFORE submit. The bulk
// endpoint has no idempotency key, so a duplicate that gets through is a
// second real rider dispatched to the same door.
const CSV_EXT = /\.csv$/i;
const EXCEL_EXT = /\.xlsx?$/i;
const digits = (v) => String(v ?? '').replace(/\D/g, '');
const text = (v) => String(v ?? '').trim();
// A sheet cell can be a number, a date, or padded text — normalise to the same
// shapes parseBulkRows yields so validateBulkRow behaves identically.
const shapeRow = (raw, line) => {
const { row, ignored } = mapSheetRow(raw);
const lat = Number(row.deliverylatitude);
const lng = Number(row.deliverylongitude);
const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0;
return {
ignored,
row: {
line,
customer_name: text(row.customer_name),
// A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91
// 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits
// and drop a leading country/trunk prefix the same way the single-order
// flow does.
customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''),
deliveryaddress: text(row.deliveryaddress),
deliverypincode: digits(row.deliverypincode),
deliverycity: text(row.deliverycity),
// Blank is meaningful: it means "quote this row from the tenant's pricing
// row and the routed distance", the same as the single-order flow. It is
// NOT zero.
finalprice: text(row.finalprice),
itemdescription: text(row.itemdescription) || 'Order',
itemcategory: text(row.itemcategory) || 'General',
quantity: Math.max(1, Number(row.quantity) || 1),
weight: text(row.weight),
// Coordinates from the sheet let the geocode pass skip this row entirely,
// which on a 200-row file is the difference between minutes and seconds.
...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {})
}
};
};
// Structural check only — enough to know the row is worth geocoding. The full
// gate is validateBulkRow, applied after coordinates exist.
const structuralError = (row) => {
if (!row.customer_name) return 'No receiver name';
if (!row.deliveryaddress) return 'No delivery address';
if (!row.customer_phone) return 'No phone number';
if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`;
return null;
};
export const mapSheetRecords = (records, headers, sheetName) => {
const rows = [];
const errors = [];
const ignoredColumns = new Set();
records.forEach((raw, i) => {
// +2: the header row is line 1, so the first data row is line 2 — the line
// number an operator sees in their own spreadsheet.
const line = i + 2;
const { row, ignored } = shapeRow(raw, line);
ignored.forEach((c) => ignoredColumns.add(c));
// A trailing blank row is an artefact of the file, not an operator error.
if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return;
const error = structuralError(row);
if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error });
else rows.push(row);
});
const normalised = headers.map(normalizeHeader);
const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c)));
return {
rows,
errors,
sheetName,
ignoredColumns: [...ignoredColumns],
// Reported, not enforced: the page only warns about these too, and a
// hand-built sheet using plain headers ("name", "phone") legitimately has
// none of the tenant's official titles while still being complete.
missingRequired,
recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()),
duplicates: findDuplicateRows(rows)
};
};
// Same recipient at the same address twice in one file. Reported, never removed
// automatically — two parcels to one door is a legitimate order, and deciding
// which is which is the operator's call, not the parser's.
export const findDuplicateRows = (rows) => {
const seen = new Map();
const dupes = [];
rows.forEach((r) => {
const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`;
if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name });
else seen.set(key, r.line);
});
return dupes;
};
export const parseBulkFile = (file) =>
new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file selected.'));
return;
}
const isCsv = CSV_EXT.test(file.name);
const isExcel = EXCEL_EXT.test(file.name);
if (!isCsv && !isExcel) {
reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`));
return;
}
if (isCsv) {
Papa.parse(file, {
header: true,
dynamicTyping: false,
skipEmptyLines: true,
complete: (results) => {
if (!results.data?.length) {
reject(new Error('That CSV has a header row but no data rows.'));
return;
}
resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name));
},
error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`))
});
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('Couldn’t read that file.'));
reader.onload = (e) => {
try {
const workbook = XLSX.read(e.target.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
// Only the first sheet is read, and the name is reported back so an
// operator whose data sits on "Sheet2" can see which one was used.
const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false });
if (!records?.length) {
reject(new Error(`Sheet “${sheetName}” is empty.`));
return;
}
resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`));
} catch (err) {
reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`));
}
};
reader.readAsBinaryString(file);
});
// ---- downloads --------------------------------------------------------------
// Hands the operator a file built from data they already supplied — a Blob
// assembled in the page, not a fetch and not an upload.
export const downloadCsv = (filename, csv) => {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
const toCsv = (headers, rows) =>
[headers, ...rows]
.map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(','))
.join('\r\n');
// A blank sheet with the exact headers this parser reads, so operators stop
// guessing at column titles.
export const templateCsv = () =>
toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]);
// The rows that did NOT go through, in the same column shape, so they can be
// fixed and re-uploaded. This is what makes a partial success recoverable
// without re-submitting the rows that already landed.
export const failedRowsCsv = (failed) =>
toCsv(
[...TEMPLATE_HEADERS, 'Reason'],
failed.map((r) => [
r.customer_name || '',
r.customer_phone || '',
r.deliveryaddress || '',
r.deliverypincode || '',
r.deliverycity || '',
r.itemdescription || '',
r.quantity ?? 1,
r.finalprice ?? '',
r.error || r.reason || 'Rejected'
])
);

View File

@@ -0,0 +1,193 @@
import { getTenantLocations } from 'pages/api/doormileApi';
import { getalltenants } from 'pages/api/api';
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational bulk create ||============================== //
//
// Same conversation shape as orderFlow.js — one question per turn, a dropdown
// wherever the page uses one — but the rows come from a sheet instead of being
// dictated one field at a time. The form version was replaced on explicit
// direction: "don't show it as the form way, it should be like chatting".
//
// The steps deliberately mirror the single-order flow's opening, because they
// ARE the same questions: which tenant, which pickup location, which service.
// Only the last step differs — a whole file instead of one recipient.
//
// What is NOT a step: locating and pricing the rows. Those are a long-running
// pass over the whole file (~1 lookup/second), so the panel runs them after the
// last answer and reports progress into the conversation. Making them a "step"
// would mean a question nobody is being asked.
const isStaffLogin = () => {
const t = localStorage.getItem('tenantid');
return !t || t === '0';
};
export const BULK_STEPS = [
{
id: 'tenantid',
type: 'select',
ask: 'Which tenant are these orders for?',
when: () => isStaffLogin(),
options: async () => {
const tenants = (await getalltenants()) || [];
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
},
apply: (d, v) => ({ ...d, tenantid: v })
},
{
id: 'pickuplocationid',
type: 'select',
// One pickup location for the whole file — the same shape the bulk page
// uses, and what makes a single batch dispatchable.
ask: 'Which business location are they all picked up from?',
options: async (d) => {
const tid = d.tenantid || localStorage.getItem('tenantid');
const locations = (await getTenantLocations(tid)) || [];
return locations.map((l) => ({
value: String(l.locationid),
label: `${l.locationname || l.address || 'Location'}${
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
}`,
record: l
}));
},
// Refused here rather than after submitting: CityGate runs server-side
// before the handler, and would reject every row in the file with an
// opaque middleware error.
validate: (v, option) =>
cityGateFor(option?.record?.pincode)
? null
: `That location’s pincode (${
option?.record?.pincode || 'unknown'
}) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
},
{
id: 'service_option',
type: 'select',
ask: 'Which service level for all of them?',
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
apply: (d, v) => ({ ...d, service_option: v })
},
{
id: 'rows',
type: 'rows',
ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
// The whole parse result is stored, not just the rows: the ignored columns
// and in-file duplicates have to be reportable, and a count of rows alone
// can't say what was quietly not read.
validate: (parsed) =>
parsed?.rows?.length
? null
: 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
}
];
export const startBulkFlow = () => {
// Same seeding rule as the single-order flow: a client login skips the tenant
// question, so the id has to be in the draft or the payload sends NaN.
const tid = localStorage.getItem('tenantid');
return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
};
export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
// ---- the long pass: locate, then price --------------------------------------
//
// Extracted from the old form so the conversation can run it and narrate it.
// Two economies keep a large file practical, and both are load-bearing:
//
// • a sheet carrying latitude/longitude columns skips the lookup entirely
// • results are cached by address, so a re-run after fixing a few rows does
// not re-look-up the ones that were already fine
//
// `shouldStop` is read through a function, never a captured boolean — as state
// it was evaluated once at call time and Stop did nothing for 200 rows.
export const GEOCODE_INTERVAL_MS = 1100;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
const located = [];
for (let i = 0; i < rows.length; i += 1) {
if (shouldStop?.()) break;
const row = rows[i];
if (hasCoords(row)) {
located.push(row);
// eslint-disable-next-line no-continue
continue;
}
const key = cacheKey(row);
if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) });
// eslint-disable-next-line no-continue
continue;
}
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
// eslint-disable-next-line no-await-in-loop
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = {
deliverylatitude: place?.geometry?.location?.lat?.(),
deliverylongitude: place?.geometry?.location?.lng?.(),
resolvedAddress: place?.formatted_address
};
cache?.set(key, found);
located.push({ ...row, ...found });
// Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast.
// eslint-disable-next-line no-await-in-loop
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
}
// Rows never reached because Stop was pressed keep no coordinates, so they
// report as unsendable instead of vanishing from the count.
if (located.length < rows.length) located.push(...rows.slice(located.length));
// Only a row that is located AND unpriced needs a routing call. Pricing an
// unlocatable row spends an OSRM request just to fail, and re-pricing a row
// that carried its own price would overwrite the operator's number.
const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
// Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
// ADDRESS lookups; pricing is unthrottled and bounded by what was already
// located. Gating it here meant a Stop mid-lookup left every located row
// unpriced and therefore unsendable — throwing away exactly the work the
// operator is told is kept.
const priced = needPricing.length
? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
: [];
const pricedByLine = new Map(priced.map((r) => [r.line, r]));
const checked = located.map((r) => {
const merged = pricedByLine.get(r.line) || r;
return {
...merged,
// The pricing reason is more specific than "Price must be a number", so it
// wins when both apply.
error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
};
});
return {
rows: checked,
valid: checked.filter((r) => !r.error),
invalid: checked.filter((r) => r.error)
};
};
// How many rows still need a network lookup — the only honest basis for an ETA.
export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
export const batchCount = (n) => Math.ceil(n / BULK_MAX);

View File

@@ -0,0 +1,261 @@
import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
import { buildOrderPayload } from './orderActions';
// ==============================|| Doormile AI — bulk order creation ||============================== //
//
// Third write capability, and the highest-blast-radius one: a single press can
// dispatch dozens of riders. Everything here is built around making that
// visible BEFORE it happens and legible AFTER.
//
// Shared with the single-order path on purpose:
// • buildOrderPayload — so a bulk row and a single order are byte-identical
// on the wire, including the pickuplocationid workaround (that field 500s
// server-side; raw pickup fields are sent instead).
// • validateOrderDraft — the same gate, applied per row.
// Server cap, documented in express-console-api.md. Exceeding it is a hard
// error rather than a silent truncation, so rows are chunked instead.
export const BULK_MAX = 200;
export const CREATE_BULK_TRIGGER =
/\b(?:create|add|place|book|new|bulk|multiple)\s+(?:multiple|many|several|bulk|\d+)\s*(?:orders|bookings|deliveries)\b|\bbulk\s+(?:order|booking|upload)\b|\bmultiple\s+orders\b/i;
// ---- Parsing a pasted list --------------------------------------------------
//
// Operators paste from a spreadsheet, so accept the shapes that actually
// arrive: comma, tab or pipe separated, one row per line, with an optional
// header row.
//
// name, phone, address, pincode, city, price, description
//
// Anything unparseable becomes a REPORTED row error rather than a silently
// dropped line — a bulk import that quietly skips row 14 is worse than one
// that refuses.
const SPLIT = /\t|\||,(?![^(]*\))/;
const HEADER_HINT = /name|phone|mobile|address|pincode|city|price|amount|item|description/i;
export const parseBulkRows = (text) => {
const lines = String(text || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return { rows: [], errors: [] };
// Drop a header row only when it looks like one AND carries no phone number.
const first = lines[0];
const looksLikeHeader = HEADER_HINT.test(first) && !/\d{10}/.test(first);
const body = looksLikeHeader ? lines.slice(1) : lines;
const rows = [];
const errors = [];
body.forEach((line, i) => {
const parts = line.split(SPLIT).map((p) => p.trim());
const lineNo = (looksLikeHeader ? 2 : 1) + i;
if (parts.length < 4) {
errors.push({ line: lineNo, text: line, reason: 'Needs at least name, phone, address and pincode' });
return;
}
const [customer_name, customer_phone, deliveryaddress, deliverypincode, deliverycity, finalprice, itemdescription] = parts;
rows.push({
line: lineNo,
customer_name,
customer_phone: String(customer_phone || '').replace(/\D/g, ''),
deliveryaddress,
deliverypincode: String(deliverypincode || '').replace(/\D/g, ''),
deliverycity: deliverycity || '',
finalprice: finalprice || '',
itemdescription: itemdescription || 'Order',
itemcategory: 'General',
quantity: 1
});
});
return { rows, errors };
};
// Per-row validation. Deliberately NOT validateOrderDraft: a pasted row has no
// coordinates (there is no address search on a paste), and the single-order
// gate requires them. Bulk rows are geocoded by the caller before submit, and
// rows that fail to geocode are reported, not sent.
const PHONE_RE = /^\d{10}$/;
export const validateBulkRow = (row) => {
if (!row.customer_name) return 'Missing customer name';
if (!PHONE_RE.test(row.customer_phone)) return 'Phone must be exactly 10 digits';
if (!row.deliveryaddress) return 'Missing delivery address';
if (!row.deliverypincode) return 'Missing delivery pincode';
// Coordinates are checked BEFORE the price, because an unlocatable address is
// the root cause and a blank price is its symptom — the row was never priced
// precisely because there was nothing to route. Reporting "price must be a
// number" here sent the operator to fix the wrong column.
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
return 'Address could not be located — the order could not be routed';
}
if (row.finalprice === '' || Number.isNaN(Number(row.finalprice))) return 'Price must be a number';
return null;
};
// ---- per-row pricing --------------------------------------------------------
//
// A blank price column means "quote it", exactly as the single-order flow does —
// not zero. The tenant's pricing row is fetched ONCE for the whole file (fetching
// it per row would be 200 identical requests), then each unpriced row costs one
// OSRM call for its routed distance.
//
// A row that can't be priced keeps its blank price and carries the reason. It
// then fails validateBulkRow and is reported, rather than being submitted at a
// number nobody chose.
export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, shouldStop } = {}) => {
const pricing = (await getAdminPricing()) || [];
// tenantid is numeric on the pricing row and a string from localStorage — a
// strict comparison here silently priced every order at zero once before.
const match = pricing.find((p) => String(p.tenantid) === String(tenantid));
const out = [];
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
// A stop leaves the remaining rows exactly as they were — unpriced and
// therefore invalid — instead of half-pricing the file.
if (shouldStop?.()) {
out.push(...rows.slice(i));
break;
}
onProgress?.({ done: i, total: rows.length, current: row.customer_name || row.deliveryaddress });
if (String(row.finalprice ?? '') !== '') {
out.push(row);
// eslint-disable-next-line no-continue
continue;
}
if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
// eslint-disable-next-line no-continue
continue;
}
// eslint-disable-next-line no-await-in-loop
const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
).catch(() => null);
if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' });
// eslint-disable-next-line no-continue
continue;
}
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
out.push({ ...row, finalprice: Number(Number(total).toFixed(2)), km, quoted: true });
}
return out;
};
// ---- double-submit protection ----------------------------------------------
//
// POST /admin/expressbooking/bulk takes no idempotency key, so if a submit times
// out the operator cannot tell what landed — and re-sending the file double-books
// every row that succeeded. Fingerprints of what has already been submitted this
// session are kept so an identical re-submit can at least be questioned.
//
// Session-scoped on purpose: it guards the realistic accident (pressing Create
// twice, or re-uploading the same file minutes later), not a next-day re-run,
// which may be a legitimately repeated delivery round.
const submitted = new Set();
export const rowSetFingerprint = (rows) =>
(rows || [])
.map((r) => `${r.customer_phone}|${r.deliverypincode}|${r.finalprice}`)
.sort()
.join(';');
export const wasAlreadySubmitted = (rows) => rows?.length > 0 && submitted.has(rowSetFingerprint(rows));
const chunk = (arr, size) => {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
};
// The only bulk-writing function in the assistant.
//
// Returns a per-row outcome, never a bare success/failure. A partial success
// is the normal case for a bulk import, and the operator has to be able to see
// exactly which rows landed — otherwise the only safe response to any error is
// to assume nothing worked and re-submit everything, which double-books.
export const executeCreateBulk = async (rows, shared) => {
const started = Date.now();
// Recorded BEFORE the request, not after: a timed-out submit is the case that
// most needs the warning, and it never reaches a success handler.
submitted.add(rowSetFingerprint(rows));
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, shared.__pickup));
const batches = chunk(payloads, BULK_MAX);
const sourceCalls = [];
let created = 0;
const failures = [];
for (let b = 0; b < batches.length; b += 1) {
const batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try {
// eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch);
const perRow = Array.isArray(res?.data) ? res.data : Array.isArray(res?.results) ? res.results : null;
if (res?.success === false) {
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
sourceCalls.push({
name: 'createExpressBookingBulk',
target: `POST /admin/expressbooking/bulk${label}`,
status: 'error',
errorMessage: res.message || 'Rejected'
});
// eslint-disable-next-line no-continue
continue;
}
// The endpoint is documented as returning per-row results. If it does,
// trust it row by row; if it doesn't, treat the batch as all-or-nothing
// rather than inventing a success count.
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 += batch.length;
}
sourceCalls.push({
name: 'createExpressBookingBulk',
target: `POST /admin/expressbooking/bulk${label}`,
status: 'complete',
stats: `${batch.length} submitted`
});
} catch (err) {
// doormileAxios rejects with the response BODY; the status is attached
// as `err.httpStatus`.
const status = err.httpStatus;
const reason = err.message || 'Request failed';
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason })));
sourceCalls.push({
name: 'createExpressBookingBulk',
target: `POST /admin/expressbooking/bulk${label}`,
status: 'error',
errorMessage: `${status || 'network'} · ${reason}`
});
}
}
return {
ok: created > 0,
created,
failed: failures.length,
failures,
batches: batches.length,
sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
};
};

View File

@@ -0,0 +1,142 @@
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
// ==============================|| Doormile AI — conversational create-customer ||============================== //
//
// Asks for one field at a time, then shows what it will send and waits for
// Submit.
//
// ---- Why this lives here and not in the router ------------------------------
//
// A first attempt at this shipped and broke immediately: the operator typed
// "create customer", was asked for a phone number, replied "8494948494", and
// got "I can't answer that one yet".
//
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
// routed to the fallback and discarded. Threading a partial draft through the
// router's `context` didn't help, because the router had already failed to
// choose an intent before the draft was ever consulted.
//
// So the conversation is owned by the PANEL, which checks for an active flow
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
// the only arrangement where "8494948494" can't be misread as a question.
//
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
// name and a 10-digit phone are required, everything else is optional and
// skippable.
const PHONE_RE = /^\d{10}$/;
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
// operator has no way past an optional field except inventing a value.
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
// `validate` returns an error string to re-ask with, or null to accept.
export const CUSTOMER_STEPS = [
{
field: 'name',
ask: 'What’s the customer’s name?',
required: true,
apply: (draft, text) => {
const [firstname, ...rest] = String(text).trim().split(/\s+/);
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
},
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
},
{
field: 'phone',
ask: 'And their 10-digit mobile number?',
required: true,
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
// are both accepted rather than rejected on formatting.
validate: (text) => {
const digits = String(text)
.replace(/\D/g, '')
.replace(/^91(?=\d{10}$)/, '');
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
}
},
{
field: 'email',
ask: 'Email address? (say “skip” if there isn’t one)',
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
},
{
field: 'address',
ask: 'Address? (or “skip”)',
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
},
{
field: 'city',
ask: 'City? (or “skip”)',
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
},
{
field: 'postcode',
ask: 'Postcode? (or “skip”)',
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
}
];
// Starts the flow, pre-filling anything already said in the opening message —
// "create a customer Ramesh 9876543210" should not then ask for the name and
// the phone it was just given.
export const startCustomerFlow = (text) => {
const draft = parseCustomerDraft(text);
return advance({ kind: 'createCustomer', step: 0, draft });
};
// Moves to the next step that still needs an answer. Returns either a question
// to ask, or the finished proposal.
export const advance = (flow) => {
let { step } = flow;
const { draft } = flow;
while (step < CUSTOMER_STEPS.length) {
const s = CUSTOMER_STEPS[step];
const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step }, ask: s.ask, done: false };
}
// Every step visited. The required check is repeated here rather than
// trusted from the walk above, so a skipped-but-required field can never
// reach a proposal.
const { ok, missing } = validateCustomerDraft(draft);
if (!ok) {
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
}
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
};
// Applies one answer. Returns the next question, or the completed proposal, or
// a re-ask when the answer didn't validate.
export const answerStep = (flow, text) => {
const s = CUSTOMER_STEPS[flow.step];
if (!s) return advance(flow);
if (isSkip(text)) {
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
// Skipped optional field: step past it without writing anything, so the
// payload builder drops it rather than sending an empty string.
return advance({ ...flow, step: flow.step + 1 });
}
const error = s.validate?.(text);
if (error) return { flow, ask: error, done: false, retry: true };
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
};
// Human-readable summary of what will be sent, for the confirm step.
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));

View File

@@ -0,0 +1,83 @@
// ==============================|| Doormile AI — conversational flow engine ||============================== //
//
// One step-walker, shared by every conversational create (orderFlow.js,
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
// create became a conversation too — a second copy would have been a third
// definition of the same branching rules to keep in sync.
//
// A step is a plain object:
//
// id required. Also the draft key the answer lands on.
// type 'select' → the panel renders a dropdown (AIFlowStep)
// 'rows' → the panel renders the file/paste input
// 'text' → answered through the composer
// ask the question
// when (draft) => boolean. Omitted means always asked. THIS is branching.
// options async (draft) => [{ value, label, record? }] — for 'select'
// validate (raw, option) => error | null. Re-asks; stores nothing.
// resolve async (raw) => { value } | { error }. May fail and re-ask —
// geocoding. A value the rest of the flow depends on is never
// stored half-resolved.
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
// from real data and is only ASKED when that fails, with the reason.
// apply (draft, value, option) => draft
//
// A step is skipped when `when` is false OR when `draft[step.id]` is already
// set — which is what lets a caller seed the draft (a client login's tenant) or
// one step fill several fields (picking an existing customer).
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
// Finds the next step that applies and hasn't been answered. Async because a
// step may answer itself from the network before we know whether to ask it.
export const advanceFlow = async (steps, flow) => {
let { step, draft } = flow;
while (step < steps.length) {
const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
if (s.auto) {
// eslint-disable-next-line no-await-in-loop
const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value);
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
}
return { flow: { ...flow, step, draft }, step: s, done: false };
}
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
};
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
export const answerFlowStep = async (steps, flow, raw, option) => {
const s = steps[flow.step];
if (!s) return advanceFlow(steps, flow);
if (s.validate) {
const error = s.validate(raw, option);
if (error) return { flow, step: s, ask: error, done: false, retry: true };
}
let value = raw;
if (s.resolve) {
const resolved = await s.resolve(raw);
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
value = resolved.value;
}
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
};

View File

@@ -15,7 +15,6 @@ import {
getCompetitorBranches,
getCarrierPricing,
getMilerSummary,
getBookingTrack,
getMilerActivity,
trackConsignment,
getConsignmentLogs,
@@ -31,6 +30,8 @@ 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 { CREATE_BULK_TRIGGER } from './bulkOrderActions';
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
// ==============================|| Doormile Bot — intent catalog ||============================== //
@@ -709,6 +710,14 @@ const nameAfterKeyword = (text, keyword) => {
// riderLookup just because it contains the word "rider".
const LOOKUP_TRIGGER = /\b(?:find|where\s+is|status\s+of|search(?:\s+for)?|lookup)\b/i;
// Questions ABOUT Doormile or about the assistant itself, rather than about
// the data. Kept tight: "how many doormile orders today" mentions the name but
// is an orders question, and must not be caught here.
// `['’]s|s` covers "what's", "what’s" and the apostrophe-less "whats" people
// actually type.
const ABOUT_TRIGGER =
/\b(?:what|who)(?:['’]s|s|\s+is|\s+are)\s+doormile\b|\b(?:tell\s+me\s+)?about\s+doormile\b|\bdoormile\s*\.\s*com\b|\bwho\s+are\s+you\b|\bwhat\s+(?:can|do)\s+you\s+(?:do|answer|help)\b|\bwhat\s+are\s+you\b/i;
// Guards against the class of bug found live: "how many riders are active
// today" contains the word "active", which is ALSO a valid order status
// (in-transit) — statusBreakdown's match used to fire on that word alone and
@@ -731,16 +740,30 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// Ordered FIRST, ahead of createOrder: "create multiple orders" also
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger
// has to get first refusal or every bulk request opens the single form.
id: 'createBulkOrders',
label: 'Create several orders from a sheet or a paste — e.g. "bulk upload orders"',
match: (text) => (CREATE_BULK_TRIGGER.test(text) ? {} : null),
run: async () => ({
headline: 'Upload a sheet or paste your rows and I’ll create them.',
form: { kind: 'createBulkOrders', status: 'open' },
sourceCalls: []
})
},
{
// Ordered FIRST, above createCustomer: "create an order" contains
// "order", which orderLookup / orderQuery / totalOrders all match on.
// Like createCustomer this NEVER mutates — it opens a form, and the panel
// submits only when the operator presses Create.
// Like createCustomer this NEVER mutates — the panel intercepts this and
// starts a CONVERSATION (orderFlow.js); the mutation fires only when the
// operator presses Create on the priced confirmation at the end.
id: 'createOrder',
label: 'Create an order — e.g. "create an order"',
match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null),
run: async () => ({
headline: 'Fill this in and I’ll create the order.',
headline: 'Let’s build the order.',
form: { kind: 'createOrder', status: 'open' },
sourceCalls: []
})
@@ -785,11 +808,15 @@ const INTENTS = [
},
{
id: 'orderLookup',
label: 'Status of a specific order — e.g. "status of order #1234"',
label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"',
match: (text) => {
if (!/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
const ref = orderIdFromWords(text);
if (!ref) return null;
// A STRONG reference (DM-…, #1234) is the whole question — an operator
// pasting a booking number shouldn't have to wrap a sentence around it.
// A WEAK one (bare digits) still needs an order/booking/status/where
// word, or a stray "42" would be read as an order id.
if (!ref.strong && !/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
return { orderId: ref.id, strong: ref.strong };
},
run: async ({ orderId, strong }) => {
@@ -826,18 +853,21 @@ const INTENTS = [
// Resolve the rider to a NAME (the booking only carries an id), and pull
// the tracking trail. Both are enrichment: either failing degrades that
// one line rather than the whole answer.
const [milers, track] = await Promise.all([
// A booking carries `appcustomerid`, never the recipient's name — so the
// customer store is read to turn it into one. All three are enrichment:
// any of them failing costs that one line, not the whole answer.
// GET /admin/bookings/:id/track is deliberately NOT called. Its response
// shape was never confirmed (express-console-api.md lists it as
// written-but-unproven), so it contributed a "Tracking" line nobody could
// rely on and an audit entry that reported an error on every order that
// simply has no trail yet. Removed on explicit direction — don't add it
// back without a confirmed response shape.
const [milers, customers] = await Promise.all([
found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null),
getBookingTrack(found.bookingid).catch(() => null)
found.appcustomerid ? getAdminCustomers().catch(() => null) : Promise.resolve(null)
]);
const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null;
// GET /admin/bookings/:id/track has no documented response shape
// (express-console-api.md lists it as written-but-unproven), so read it
// defensively: report only what is unambiguously there, and stay silent
// rather than rendering fields that may not mean what their names imply.
const steps = Array.isArray(track) ? track : null;
const lastStep = steps && steps.length ? steps[steps.length - 1] : null;
const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : null;
const extraCalls = [];
if (milers) {
@@ -848,21 +878,14 @@ const INTENTS = [
stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id'
});
}
extraCalls.push(
steps
? {
name: 'getBookingTrack',
target: `/admin/bookings/${found.bookingid}/track`,
status: 'complete',
stats: `${steps.length} events`
}
: {
name: 'getBookingTrack',
target: `/admin/bookings/${found.bookingid}/track`,
status: 'error',
errorMessage: 'No tracking data returned'
}
);
if (customers) {
extraCalls.push({
name: 'getAdminCustomers',
target: '/admin/customers',
status: 'complete',
stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id'
});
}
const riderLine = found.assignedmileruserid
? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${
@@ -870,19 +893,63 @@ const INTENTS = [
}`
: 'Not yet assigned to a rider.';
const service = found.serviceoptions?.[0];
const parcels = found.parcels || [];
const addr = (a, pin, city) => [a, city, pin].filter(Boolean).join(', ');
// Every field that is actually on the record, and nothing that isn't — a
// row is omitted rather than rendered as "—", so a blank never reads as
// "we checked and it's empty" when it means "this booking has no such
// field at all".
const items = [
{ label: 'Status', meta: `${status}${found.status && found.status !== status ? ` (${found.status})` : ''}` },
{ label: 'Rider', meta: riderLine.replace(/^Rider: /, '') },
customer || found.appcustomerid
? {
label: 'Customer',
meta: customer
? [customer.name || [customer.firstname, customer.lastname].filter(Boolean).join(' '), customer.phone]
.filter(Boolean)
.join(' · ')
: `#${found.appcustomerid} (name unavailable)`
}
: null,
found.pickupaddress ? { label: 'Pickup', meta: addr(found.pickupaddress, found.pickuppincode) } : null,
found.deliveryaddress ? { label: 'Drop', meta: addr(found.deliveryaddress, found.deliverypincode, found.deliverycity) } : null,
service
? {
label: 'Service',
meta: [service.servicetype, service.estimatedprice != null ? `₹${Number(service.estimatedprice).toFixed(2)}` : null]
.filter(Boolean)
.join(' · ')
}
: null,
parcels.length
? {
label: `Parcel${parcels.length === 1 ? '' : `s (${parcels.length})`}`,
meta: parcels
.slice(0, 3)
.map((p) => [p.itemcategory, p.itemdescription].filter(Boolean).join(' · '))
.join(' | ')
}
: null,
found.createdat ? { label: 'Created', meta: parseDoormileTimestamp(found.createdat).format('DD MMM YYYY, hh:mm A') } : null,
found.updatedat ? { label: 'Last updated', meta: parseDoormileTimestamp(found.updatedat).format('DD MMM YYYY, hh:mm A') } : null,
etaOf(found) ? { label: 'Promised by', meta: parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A') } : null,
service?.sladueat ? { label: 'SLA due', meta: parseDoormileTimestamp(service.sladueat).format('DD MMM, hh:mm A') } : null,
found.consignmentid ? { label: 'Consignment', meta: `#${found.consignmentid}` } : null,
found.bookingsource ? { label: 'Source', meta: found.bookingsource } : null,
found.notes ? { label: 'Notes', meta: found.notes } : null
].filter(Boolean);
return {
headline: `Order ${bookingLabel(found)} is ${status}.`,
detail: [
riderLine,
found.pickupaddress ? `Pickup: ${found.pickupaddress}` : null,
found.deliveryaddress ? `Drop: ${found.deliveryaddress}` : null,
etaOf(found) ? `Promised by ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}` : null,
steps && steps.length
? `${steps.length} tracking event${steps.length === 1 ? '' : 's'}${lastStep?.status ? `, latest: ${lastStep.status}` : ''}`
: null
]
.filter(Boolean)
.join('\n'),
list: { title: 'The full record', numbered: false, items },
// Lateness is the one thing worth saying before the rows are read.
detail:
delayState(found, dayjs()) === 'late'
? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.`
: undefined,
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls]
};
}
@@ -1940,6 +2007,37 @@ const INTENTS = [
sourceCalls: [scanCall(scan, `${rows.length} matched`)]
};
}
},
{
// LAST in the catalog on purpose: every operational intent gets first
// refusal, so this can only ever claim a question none of them recognised.
// Its trigger is deliberately narrow — a question that merely CONTAINS the
// word "doormile" ("how many doormile orders today") must still route to
// the intent that can actually count it.
id: 'aboutDoormile',
label: 'What Doormile is, and what I can answer — e.g. "what is doormile"',
match: (text) => (ABOUT_TRIGGER.test(text) ? {} : null),
run: async () => ({
// Only what this console demonstrably does. Nothing here is a claim about
// the company, its coverage, its pricing or its history — none of that is
// in this app, and inventing it would be exactly the failure mode the
// whole catalog is built to avoid. doormile.com is where that lives.
headline: 'Doormile is the delivery operation this console runs.',
detail:
'From here you manage orders and deliveries, dispatch riders in batches, run hubs and vehicles, and handle tenants, pricing and reports. I answer questions about that live data — I don’t hold Doormile’s own company information, so for anything beyond day-to-day operations see doormile.com.',
list: {
title: 'What I can answer',
numbered: false,
items: [
{ label: 'Orders', meta: 'counts by status, batch, tenant or rider · revenue · delays · one order by its number' },
{ label: 'Riders', meta: 'how many are active · what a named rider has done' },
{ label: 'Fleet', meta: 'hubs, vehicles, tripsheets and exceptions' },
{ label: 'Business', meta: 'tenants, customers, pricing, consignments, partners' },
{ label: 'Creating', meta: 'a customer, one order, or many from a spreadsheet' }
]
},
sourceCalls: []
})
}
];
@@ -2079,6 +2177,31 @@ const answerMultiPart = async (text) => {
export async function answerQuestion(text, context = {}) {
const normalized = correctTypos(text);
// ---- Semantic routing (optional) ---------------------------------------
//
// Tried FIRST, because the regex catalog's weakness is vocabulary, not
// logic: "cancellation" not matching `cancel(led)?`, a bare reply matching
// nothing, "per day" being dropped. Retrieval fixes the matching problem
// without touching how an answer is produced — the intent's own run() still
// executes and every number still comes from a live API call.
//
// Returns null whenever the sidecar is absent, slow, or unsure, in which
// case the deterministic matcher below runs exactly as it does today. This
// path can only add coverage.
const routed = await routeQuestion(normalized);
if (isRouteTrustworthy(routed)) {
const intent = INTENTS_BY_ID[routed.intentId];
// The intent's own match() still extracts the slots — dates, statuses,
// tenants, riders. Retrieval decides WHICH question; parsing decides WITH
// WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized);
if (intent && params) {
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed };
}
}
// Tried BEFORE the single-intent pass: several intents match on a bare
// substring ("revenue" anywhere in the text) and their `run` never
// returns null, so on a combined question like "orders and revenue
@@ -2101,5 +2224,40 @@ export async function answerQuestion(text, context = {}) {
if (followUp) return followUp;
}
// ---- Last resort: is this a question about how the console WORKS? -------
//
// Reached only when no intent produced an answer. "What is CityGate", "why
// does dispatch reconcile before commit", "what's the pagination cap" are
// real operator questions that no amount of API access can answer — they're
// answered by the documentation.
//
// Passages are returned VERBATIM with their source. There is no generation
// step: summarising would need a hosted model (CLAUDE.md §2) and would let a
// paraphrase drift from what the doc actually says. The operator reads the
// real words and can see which file they came from.
const docs = await askDocs(normalized);
if (docs?.chunks?.length) {
const best = docs.chunks[0];
return {
headline: best.heading || 'From the documentation',
detail: best.text,
list:
docs.chunks.length > 1
? {
title: 'Other passages',
numbered: false,
items: docs.chunks.slice(1).map((c) => ({ label: c.heading || c.source, meta: c.source }))
}
: undefined,
intentId: 'docsAnswer',
sourceCalls: docs.chunks.map((c) => ({
name: 'console_docs',
target: c.source,
status: 'complete',
stats: `similarity ${c.score}`
}))
};
}
return null;
}

View File

@@ -139,7 +139,14 @@ export const executeCreateOrder = async (payload) => {
};
} catch (err) {
const duration = `${Date.now() - started}ms`;
const message = err.response?.data?.message || err.message || 'The order could not be created.';
const status = err.httpStatus ?? err.response?.status;
const serverMessage = err.message || err.error;
const message =
status === 404 || status === 405
? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
: status
? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
: `${serverMessage || 'The request failed'} — nothing was saved.`;
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
}
};

View File

@@ -0,0 +1,267 @@
import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
import { getalltenants } from 'pages/api/api';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational create-order ||============================== //
//
// One question at a time, mirroring createorder1.js's own field set and using a
// DROPDOWN wherever that page uses one — business location, customer, category,
// weight, service tier. Free text is only for things that genuinely are free
// text (a name, an address, a description).
//
// Two capabilities the customer flow didn't need:
//
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
// straight to picking from the real customer list, new asks for the fields.
// Steps carry a `when` predicate and are skipped when it's false.
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
// dropdown never shows a stale or invented list.
//
// The conversation is driven by the PANEL, which intercepts replies before the
// router ever sees them — see customerFlow.js for why that matters (a bare
// "8494948494" matches no intent and used to be discarded).
const PHONE_RE = /^\d{10}$/;
const digits = (t) => String(t || '').replace(/\D/g, '');
const isStaffLogin = () => {
const t = localStorage.getItem('tenantid');
return !t || t === '0';
};
// ---- steps ------------------------------------------------------------------
//
// type: 'select' → the panel renders a dropdown from `options(draft)`
// 'text' → answered through the composer
// when: omitted means always asked
export const ORDER_STEPS = [
{
id: 'tenantid',
type: 'select',
ask: 'Which tenant is this order for?',
// A client login already has its tenant; only Doormile staff choose.
when: () => isStaffLogin(),
options: async () => {
const tenants = (await getalltenants()) || [];
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
},
apply: (d, v) => ({ ...d, tenantid: v })
},
{
id: 'pickuplocationid',
type: 'select',
ask: 'Which business location is this picked up from?',
options: async (d) => {
const tid = d.tenantid || localStorage.getItem('tenantid');
const locations = (await getTenantLocations(tid)) || [];
return locations.map((l) => ({
value: String(l.locationid),
// The pincode is shown because it decides CityGate — a location outside
// the open cities will be refused server-side, and the operator should
// see that before choosing rather than after submitting.
label: `${l.locationname || l.address || 'Location'}${
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
}`,
record: l
}));
},
validate: (v, option) =>
cityGateFor(option?.record?.pincode)
? null
: `That location’s pincode (${
option?.record?.pincode || 'unknown'
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
},
{
id: 'customerMode',
type: 'select',
ask: 'Is this an existing customer, or a new one?',
options: async () => [
{ value: 'existing', label: 'Existing customer' },
{ value: 'new', label: 'New customer' }
],
apply: (d, v) => ({ ...d, customerMode: v })
},
{
id: 'existingCustomer',
type: 'select',
ask: 'Which customer?',
when: (d) => d.customerMode === 'existing',
options: async () => {
const customers = (await getAdminCustomers()) || [];
return customers
.filter((c) => c.phone)
.map((c) => ({
value: String(c.appcustomerid ?? c.id),
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
record: c
}));
},
// Picking an existing customer fills the name and phone, so the two
// free-text steps below are skipped by their own `when`.
apply: (d, v, option) => ({
...d,
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
customer_phone: digits(option?.record?.phone)
})
},
{
id: 'customer_name',
type: 'text',
ask: 'What’s the customer’s name?',
when: (d) => d.customerMode === 'new' && !d.customer_name,
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
},
{
id: 'customer_phone',
type: 'text',
ask: 'And their 10-digit mobile number?',
when: (d) => !d.customer_phone,
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
},
{
id: 'deliveryaddress',
type: 'text',
ask: 'Where is it being delivered? Give the full address.',
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
// an address that can't be located is refused here rather than becoming a
// booking nothing can dispatch.
resolve: async (t) => {
const place = await geocodeAddress(String(t).trim()).catch(() => null);
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
(place.address_components || []).forEach((c) => {
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
});
return {
value: {
...parts,
deliverylatitude: place.geometry?.location?.lat?.(),
deliverylongitude: place.geometry?.location?.lng?.()
}
};
},
apply: (d, v) => ({ ...d, ...v })
},
{
id: 'deliverypincode',
type: 'text',
ask: 'What’s the delivery pincode?',
when: (d) => !d.deliverypincode,
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
},
{
id: 'service_option',
type: 'select',
ask: 'Which service level?',
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
apply: (d, v) => ({ ...d, service_option: v })
},
{
id: 'itemcategory',
type: 'select',
ask: 'What kind of parcel is it?',
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
apply: (d, v) => ({ ...d, itemcategory: v })
},
{
id: 'weight',
type: 'select',
ask: 'Roughly how heavy?',
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
apply: (d, v) => ({ ...d, weight: v })
},
{
id: 'itemdescription',
type: 'text',
ask: 'Briefly, what’s inside?',
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
},
{
id: 'quantity',
type: 'select',
ask: 'How many parcels?',
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
},
{
id: 'finalprice',
type: 'text',
ask: 'What should the price be? Enter the amount in ₹.',
// `auto` answers a step from real data and only falls back to asking. The
// quote is stashed either way so the confirmation can show the distance it
// measured, and say why it couldn’t price when it couldn’t.
auto: async (d) => {
const quote = await priceOrder(d);
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
return {
patch: { __quote: quote },
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
};
},
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
apply: (d, t) => ({ ...d, finalprice: Number(t) })
}
];
// Mirrors createorder1.js's own lists so the bot offers the same choices.
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
// ---- pricing ----------------------------------------------------------------
//
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
// this tenant's own pricing row. Quoted, never invented — if no pricing row
// matches, the operator is asked for the amount rather than shown a zero.
export const priceOrder = async (draft) => {
const tid = draft.tenantid || localStorage.getItem('tenantid');
const pricing = (await getAdminPricing()) || [];
// tenantid is numeric on the pricing row and a string from localStorage — a
// strict comparison here silently priced every order at zero once before.
const match = pricing.find((p) => String(p.tenantid) === String(tid));
const pickup = draft.__pickup;
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
const km = await calculateDrivingDistance(
{ latitude: pickup.latitude, longitude: pickup.longitude },
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
).catch(() => null);
if (km == null) return { error: 'could not measure the distance' };
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
return {
km,
durationMin: getLastRouteDurationMin(),
basePrice: match.baseprice,
total: Number(Number(total).toFixed(2))
};
};
// ---- engine -----------------------------------------------------------------
//
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
// These wrappers keep the order-specific names the panel and the tests use.
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
export const startOrderFlow = () => {
// A client login already belongs to a tenant, so its step is skipped — but
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
// draft is what makes the skip safe.
const tid = localStorage.getItem('tenantid');
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
};
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);

View File

@@ -0,0 +1,66 @@
// ==============================|| Doormile AI — semantic routing client ||============================== //
//
// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
// asked. It never returns data — every figure still comes from the intent's own
// deterministic run(), through the same typed API functions the pages use.
//
// The whole module is optional by design:
//
// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
// • sidecar unreachable → disabled for this call, regex matcher
// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
// • low confidence → not used, regex matcher
//
// Today's behaviour is the floor. This can raise it, never lower it.
const BASE = process.env.REACT_APP_AI_URL || '';
const ROUTE_TIMEOUT_MS = 400;
export const isRagEnabled = () => Boolean(BASE);
// Once the sidecar has failed we stop hammering it on every keystroke-fast
// question. Re-armed after a cool-off so a restarted container is picked up
// without a page reload.
let disabledUntil = 0;
const COOL_OFF_MS = 30000;
const post = async (path, body) => {
if (!BASE || Date.now() < disabledUntil) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
try {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!res.ok) throw new Error(`${res.status}`);
return await res.json();
} catch {
// Any failure — offline, timeout, 5xx — is silent by design. The operator
// gets the deterministic answer; they should never see plumbing.
disabledUntil = Date.now() + COOL_OFF_MS;
return null;
} finally {
clearTimeout(timer);
}
};
// Returns { intentId, confidence, score, margin, alternatives } or null.
export const routeQuestion = (text) => post('/route', { text });
// Documentation passages, verbatim with attribution. No generation step —
// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
// a paraphrase drift from what the doc actually says.
export const askDocs = (text) => post('/ask', { text });
// A semantic near-miss must never open a create form. Write intents require
// high confidence AND corroboration from the deterministic trigger, so the
// worst case is that the operator types the phrase the regex already knows.
export const isRouteTrustworthy = (routed) => {
if (!routed?.intentId) return false;
if (routed.isWrite) return routed.confidence === 'high';
return routed.confidence === 'high' || routed.confidence === 'medium';
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { MdPersonAddAlt1 } from 'react-icons/md';
@@ -11,7 +11,6 @@ import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Divider } from '@astryxdesign/core/Divider';
import Loader from 'components/Loader';
@@ -19,34 +18,32 @@ import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
import { createAdminCustomer } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
// Same field set as nearle_console_express's createCustomer.js (Location,
// Choose Client, Name, Phone, Email, Door No, Address, Location/City/State/
// Postcode, Landmark) — but POST /admin/tenantcustomers only persists
// { firstname, lastname, phone, email }. There is no address/coordinate
// storage anywhere on the new API for a customer record (confirmed against
// express-console-api.md — the old jupiter /customers/create body doesn't
// exist here at all). The address section below is collected and shown so
// the form matches the reference 1:1, but it is NOT sent on submit — see the
// note under that section. `tenantid` isn't in the documented POST body
// either; it's included speculatively for staff logins (every other create
// endpoint on this API takes tenantid explicitly) and may be silently
// ignored server-side — unconfirmed, this endpoint isn't in the "confirmed
// end-to-end" list.
// Writes to POST /admin/customers — the same resource the Customers page
// (customers.js) reads via getAdminCustomers(), so a customer created here
// shows up there immediately. This used to call createTenantCustomer
// (POST /admin/tenantcustomers), a different store with its own id
// sequence — a customer created that way never appeared on the Customers
// list. See src/pages/nearle/assistant/CLAUDE.md ("Customer creation writes
// to /admin/customers") for the full history; the Doormile AI assistant's
// executeCreateCustomer was fixed first, this page now matches it.
//
// This resource carries no tenantid (confirmed against a real
// GET /admin/customers response), so there's no tenant picker here — unlike
// Tenants/Riders/etc, a customer isn't scoped to a client at creation time.
// A real GET /admin/customers response does carry address, doorno, landmark,
// suburb, city, state, postcode, latitude and longitude, so the address
// section below is sent (empty fields dropped) rather than discarded.
const Createcustomer = () => {
const navigate = useNavigate();
const loggedInTenantId = localStorage.getItem('tenantid');
const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0';
const [appId, setAppId] = useState(0);
const [locaName, setLocoName] = useState('Select Location');
const [tid, setTid] = useState(isStaffLogin ? '' : loggedInTenantId);
const [firstname, setFirstname] = useState('');
const [lastname, setLastname] = useState('');
@@ -60,13 +57,6 @@ const Createcustomer = () => {
const [appLocaLat, setAppLocaLat] = useState();
const [appLocaLng, setAppLocaLng] = useState();
const { data: tenantList = [] } = useQuery({
queryKey: ['admin-tenants-picker'],
queryFn: getAdminTenants,
enabled: isStaffLogin
});
const tenantOptions = (tenantList || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
// LocationAutocomplete only reports back applocationid/locationname — it
// caches the full hub list (with lat/lng) in localStorage under a
// tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js)
@@ -123,8 +113,13 @@ const Createcustomer = () => {
const opentoast = (message) =>
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
// Empty fields are dropped rather than sent as noise — mirrors
// buildCustomerPayload in assistant/actions.js, the other caller of this
// same endpoint.
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
const createCustomerMutation = useMutation({
mutationFn: (payload) => createTenantCustomer(payload),
mutationFn: (payload) => createAdminCustomer(payload),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar('Customer created successfully', {
@@ -141,20 +136,28 @@ const Createcustomer = () => {
});
const createprofile = () => {
if (isStaffLogin && !tid) {
opentoast('Select Tenant');
} else if (!firstname) {
if (!firstname) {
opentoast('Enter Name');
} else if (!phone || phone.length !== 10) {
opentoast('Enter a valid 10-digit Mobile Number');
} else {
createCustomerMutation.mutate({
firstname,
lastname,
phone,
email,
...(isStaffLogin && tid ? { tenantid: +tid } : {})
});
createCustomerMutation.mutate(
clean({
firstname,
lastname,
phone,
email,
address: pickCust.address,
doorno,
landmark,
suburb: pickCust.suburb,
city: pickCust.city,
state: pickCust.state,
postcode: pickCust.postcode,
latitude: startPoint.latitude || undefined,
longitude: startPoint.longitude || undefined
})
);
}
};
@@ -182,16 +185,6 @@ const Createcustomer = () => {
</Text>
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
</VStack>
{isStaffLogin && (
<Selector
label="Choose Client"
options={tenantOptions}
value={tid ? String(tid) : ''}
onChange={(v) => setTid(v || '')}
placeholder="Choose Client"
hasSearch
/>
)}
<TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
<TextInput
@@ -210,11 +203,6 @@ const Createcustomer = () => {
<Divider />
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved
on submit.
</Text>
<AddressAutocomplete
id="customer-address"
label="Address"

View File

@@ -56,17 +56,39 @@ import StatCard from 'components/nearle_components/StatCard';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
import { getTenantCustomers, updateTenantCustomer } from 'pages/api/doormileApi';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================
// GET /admin/customers confirmed live: { appcustomerid, createdat, email,
// name, phone, totalbookings } — no city/address/firstname field exists at
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
// there is no create/delete here by design ("Tenant-scoped through their
// bookings" — a customer only exists once they've ordered through a tenant).
// This page reads GET /admin/tenantcustomers, NOT /admin/customers.
//
// Why: /admin/customers cannot be written to. `POST /admin/customers` returns
// **405 Method Not Allowed** (confirmed live) — the route exists and POST is
// not among its methods. On that resource a customer appears only as a side
// effect of a booking ("creates a Guest customer if unknown"). So every create
// in this console — this page's own dialog, the sidebar's Create Customer page,
// and the assistant — writes to /admin/tenantcustomers, and reading
// /admin/customers here meant none of them ever showed up.
//
// The two stores have separate id sequences: a customer created via the
// assistant came back as id 2 while the /admin/customers list ran to id 44.
// That is why the edit dialog below had to move to updateTenantCustomer as
// well — left on updateAdminCustomer it would PATCH /admin/customers/:id with
// a tenant-customer id, which is a 404 at best and edits a DIFFERENT PERSON at
// worst.
//
// A real GET /admin/customers response also disproves the note that used to
// live here claiming no address/firstname field exists — it carries firstname,
// lastname, address, doorno, landmark, suburb, city, state, postcode, latitude
// and longitude (all empty on every live record).
//
// The accessors below deliberately read BOTH shapes. /admin/customers returns a
// composed `name` and `appcustomerid`; tenant customers use firstname/lastname
// and a different id field, and Tenants.js is not reliable evidence either (it
// writes `phone` but renders `contactno`). Falling back across both means a
// field-name difference costs one column, not a table of blanks.
//
// The edit dialog replicates nearle_console_express's customer edit dialog
// field-for-field (Name, Contact, Address, Location, City, State, Postcode,
@@ -78,10 +100,11 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
const custId = (row) => row.appcustomerid;
const custName = (row) => row.name || '—';
const custPhone = (row) => row.phone || '—';
const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.customerid ?? row.id;
const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
const custPhone = (row) => row.phone || row.contactno || '—';
const custEmail = (row) => row.email || '—';
// Tenant customers carry no booking count; 0 is honest rather than blank.
const custBookings = (row) => row.totalbookings ?? 0;
// TanStack Table v9 registers features explicitly — only sorting/resizing/
@@ -122,7 +145,7 @@ const Customers = () => {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
const { data: customers = [], isLoading } = useQuery({ queryKey: ['tenant-customers'], queryFn: getTenantCustomers });
const rows = useMemo(() => {
if (!debouncedSearch) return customers;
@@ -144,11 +167,24 @@ const Customers = () => {
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const saveMutation = useMutation({
mutationFn: () => updateAdminCustomer(custId(editRow), form),
mutationFn: () => {
// Tenant customers store firstname/lastname, but this dialog edits one
// "Customer Name" field. Split on the first space so a round-trip keeps
// both parts instead of blanking the surname.
const [firstname, ...rest] = String(form.name || '')
.trim()
.split(/\s+/);
return updateTenantCustomer(custId(editRow), {
firstname: firstname || '',
lastname: rest.join(' '),
phone: form.phone || '',
email: form.email || ''
});
},
onSuccess: (res) => {
if (res.success) {
opentoast('Customer updated', 'success');
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
queryClient.invalidateQueries({ queryKey: ['tenant-customers'] });
setEditRow(null);
} else {
opentoast(res.message || 'Failed to update customer');
@@ -159,10 +195,11 @@ const Customers = () => {
const openEdit = (row) => {
setEditRow(row);
// Seeded through the accessors so the dialog works on either record shape.
setForm({
name: row.name || '',
phone: row.phone || '',
email: row.email || ''
name: custName(row) === '—' ? '' : custName(row),
phone: custPhone(row) === '—' ? '' : custPhone(row),
email: custEmail(row) === '—' ? '' : custEmail(row)
});
setAddressInput(row.address || '');
setPickAddress({

View File

@@ -7,6 +7,7 @@ dayjs.extend(utc);
import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi';
import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { BATCHES as SHARED_BATCHES, batchRangeLabel } from 'utils/batchBucket';
import {
MdOutlineDateRange,
MdDirectionsBike,
@@ -111,6 +112,10 @@ const BRAND = DT.brand;
// the canonical status registry (themes/dt/status.js) key to the
// `currentStatus` key the queries use AND to the `batchCounts` key (so the
// chip count for the tab is one lookup).
// 'other' is NOT in this list — it is appended at render time and only when
// rows actually land in it, so the strip doesn't carry a permanently-empty tab.
const KNOWN_TAB_STATUSES = ['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped', 'delivered', 'cancelled', 'canceled'];
const STATUS_TABS = [
{ status: 'pending', countKey: 'uncoveredLength' },
{ status: 'accepted', countKey: 'assignedLength' },
@@ -134,19 +139,26 @@ const KPI_META = [
// segments the day there sees the same buckets here. Hours are 24h, half-open
// [startHour, endHour) — a delivery at exactly endHour falls into the *next*
// batch (or none, if the gap isn't covered).
// Windows and ranges come from utils/batchBucket.js — the same source Dispatch
// derives its own from. They were three hand-maintained copies of numbers that
// must agree; a row bucketed one way here and another on Dispatch is the same
// bug twice. Only the presentation (colour, icon) is local.
const BATCH_PRESENTATION = {
morning: { color: '#0ea5e9', iconKey: 'morning' },
afternoon: { color: '#f59e0b', iconKey: 'afternoon' },
evening: { color: '#6366f1', iconKey: 'evening' }
};
const BATCH_OPTIONS = [
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
{
id: 'afternoon',
label: 'Afternoon Batch',
range: '9 AM to 12:30 PM',
color: '#f59e0b',
iconKey: 'afternoon',
startHour: 9,
endHour: 12.5
},
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
...SHARED_BATCHES.map((b) => ({
id: b.id,
label: b.label,
range: batchRangeLabel(b),
startHour: b.startHour,
endHour: b.endHour,
...BATCH_PRESENTATION[b.id]
}))
];
// Auto-pick the batch matching the operator's LOCAL wall-clock hour so the
@@ -571,7 +583,14 @@ const Deliveries = () => {
isLoading: countSourceIsLoading,
refetch: countSourceRefetch
} = useInfiniteQuery({
queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid],
// 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'],
queryFn: fetchDeliveries,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
});
@@ -618,7 +637,9 @@ const Deliveries = () => {
return countSourceRows.filter((r) => {
if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false;
const s = String(r.orderstatus || '').toLowerCase();
if (wantStatus && s !== wantStatus) return false;
if (wantStatus === 'other') {
if (KNOWN_TAB_STATUSES.includes(s)) return false;
} else if (wantStatus && s !== wantStatus) return false;
if (q) {
const hay = [
r.deliverycustomer,
@@ -817,8 +838,8 @@ const Deliveries = () => {
{
id: 'orderLocation',
accessorFn: (row) => row.orderid,
size: 220,
minSize: 160,
size: 240,
minSize: 180,
enableSorting: false,
meta: { label: 'Order / Location' },
header: () => (
@@ -826,46 +847,43 @@ const Deliveries = () => {
Order / Location
</Text>
),
// Order and delivery are stacked, not side by side. They used to be two
// VStacks inside an HStack, and flex items default to `min-width: auto`
// — so the order id (`DM-BK-BC87D8B0-30828`, unbreakable and wider than
// half the column) refused to shrink and crushed the delivery stack to
// zero width. A zero-width cell doesn't hide its text, it wraps it one
// character per line, which is what rendered as a vertical strip of
// letters. Two 20-character ids never fitted a 220px column; labelling
// them and giving each its own line is what actually fixes it, and the
// bare "-" separator between them conveyed nothing.
cell: ({ row }) => {
const r = row.original;
// express-console-api.md: Doormile timestamps are IST wall-clock with
// no timezone marker. A Z-suffixed response reinterpreted as local
// time shifts by 5:30 — showing the wrong time and sometimes the
// wrong day. parseDoormileTimestamp strips that, matching
// getRowBatchId above.
const ordered = parseDoormileTimestamp(r.orderdate);
const delivered = parseDoormileTimestamp(r.deliverydate);
return (
<VStack gap={0} padding={0}>
<VStack gap={0} padding={0} minWidth={0}>
<Tooltip content="Location Name-Suburb">
<Text maxLines={1}>{r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname}</Text>
</Tooltip>
<HStack gap={3}>
<VStack gap={0} padding={0}>
<Tooltip content="Order Id">
<Text maxLines={1}>{r.orderid}</Text>
</Tooltip>
{/* express-console-api.md: Doormile timestamps are IST
wall-clock with no timezone marker — bare dayjs() was
still being used here despite this comment saying
otherwise (the fix was never actually applied below,
only claimed) — a Z-suffixed response reinterprets as
local time and shifts 5:30, showing the wrong time
(and sometimes the wrong day). parseDoormileTimestamp
strips that, matching getRowBatchId above. */}
<Tooltip content="Ordered date">
<Text type="supporting">{parseDoormileTimestamp(r.orderdate).format('DD/MM/YYYY')}</Text>
</Tooltip>
<Text type="supporting" color="secondary">
{parseDoormileTimestamp(r.orderdate).format('hh:mm A')}
</Text>
</VStack>
-
<VStack gap={0} padding={0}>
<Tooltip content="Delivery Id">
<Text maxLines={1}>{r.deliveryid}</Text>
</Tooltip>
<Tooltip content="Delivery date">
<Text type="supporting">{parseDoormileTimestamp(r.deliverydate).format('DD/MM/YYYY')}</Text>
</Tooltip>
<Text type="supporting" color="secondary">
{parseDoormileTimestamp(r.deliverydate).format('hh:mm A')}
</Text>
</VStack>
</HStack>
<Tooltip content="Order Id">
<Text maxLines={1}>{r.orderid}</Text>
</Tooltip>
<Text type="supporting" color="secondary">
Ordered {ordered.format('DD/MM/YYYY · hh:mm A')}
</Text>
<Tooltip content="Delivery Id">
<Text maxLines={1}>{r.deliveryid}</Text>
</Tooltip>
<Text type="supporting" color="secondary">
Delivery {delivered.format('DD/MM/YYYY · hh:mm A')}
</Text>
</VStack>
);
}
@@ -1140,6 +1158,16 @@ const Deliveries = () => {
const colCount = table.getVisibleLeafColumns().length;
// The distinct statuses that matched no tab, named so the fix is obvious.
const unmappedStatuses = useMemo(() => {
const seen = new Set();
countSourceRows.forEach((r) => {
const s = String(r.orderstatus || '').toLowerCase();
if (s && !KNOWN_TAB_STATUSES.includes(s)) seen.add(s);
});
return [...seen];
}, [countSourceRows]);
// Counts per status, scoped to the selected batch. Keys mirror the legacy
// *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical
// (countData?.uncoveredLength → batchCounts.uncoveredLength).
@@ -1147,6 +1175,7 @@ const Deliveries = () => {
const c = {
uncoveredLength: 0,
assignedLength: 0,
otherLength: 0,
arrivedLength: 0,
pickedLength: 0,
activeLength: 0,
@@ -1184,7 +1213,12 @@ const Deliveries = () => {
case 'canceled':
c.cancelLength += 1;
break;
// Every status this page knows has a case above. Anything else — a
// backend enum nobody has mapped yet — used to fall through here AND
// fail every tab's equality filter, so the row was counted nowhere and
// displayed nowhere: silent data loss, not an empty state.
default:
c.otherLength += 1;
break;
}
});
@@ -1540,7 +1574,15 @@ const Deliveries = () => {
const idx = STATUS_TABS.findIndex((t) => t.status === status);
if (idx !== -1) handleChangetab(idx);
}}
tabs={STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 }))}
tabs={[
...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })),
// Only ever rendered when a status arrived that this page has
// no tab for. Its presence is the signal that the mapping in
// api.js needs extending — the rows are reachable meanwhile.
...(batchCounts.otherLength > 0
? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }]
: [])
]}
size="sm"
hasDivider={false}
/>
@@ -1781,7 +1823,13 @@ const Deliveries = () => {
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{ width: header.getSize(), minWidth: header.column.columnDef.minSize, maxWidth: header.column.columnDef.maxSize || header.getSize(), textAlign: align, position: 'relative' }}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
@@ -2113,7 +2161,13 @@ const Deliveries = () => {
/>
<Selector
label="Status"
options={['pending', 'accepted', 'started', 'arrived', 'delivered', 'cancelled'].map((s) => ({
// 'picked' has to be here even though nothing sets it from
// this dialog: the dialog opens with the row's CURRENT status
// selected, and Converted_To_Consignment now classifies as
// picked — an option list without it would open blank on
// exactly the rows this dialog is reachable from (it needs a
// consignmentid, which is what being picked means).
options={['pending', 'accepted', 'started', 'arrived', 'picked', 'delivered', 'cancelled'].map((s) => ({
value: s,
label: getStatusMeta(s).label
}))}

View File

@@ -9,20 +9,30 @@ Rules for editing `Dispatch.js`, `Preview.js`, `CompareDataPanel.js`, and `dispa
Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors them — **the two pages must agree on which batch a given row belongs to**, otherwise the same delivery shows up in one batch on one page and a different batch on the other.
**The windows live in `utils/batchBucket.js` now.** Dispatch derives `BATCHES_DEFAULT_RAW` from it and `deliveries.js` derives `BATCH_OPTIONS` from it, so the two pages cannot disagree by construction. Only presentation (colour, icon) is page-local.
```js
// BATCHES_DEFAULT_RAW — half-open [startHour, endHour) in LOCAL time, not UTC
// utils/batchBucket.js — half-open [startHour, endHour) in LOCAL time, not UTC
[
{ id: 'morning', startHour: 0, endHour: 8 }, // 12 AM – 8 AM
{ id: 'afternoon', startHour: 9, endHour: 12.5 }, // 9 AM – 12:30 PM
{ id: 'evening', startHour: 16, endHour: 19 } // 4 PM – 7 PM
{ id: 'morning', startHour: 0, endHour: 9 }, // 12 AM – 9 AM
{ id: 'afternoon', startHour: 9, endHour: 16 }, // 9 AM – 4 PM
{ id: 'evening', startHour: 16, endHour: 24 } // After 4 PM
]
```
**Gaps are intentional** (8–9 AM, 12 PM–4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one.
**They cover the whole day, and that is load-bearing.** They used to be 0–8 / 9–12.5 / 16–19 — 14.5 of 24 hours, with gaps at 8–9 AM, 12:30–4 PM and after 7 PM, documented here as intentional. They weren't wrong when they were written: they came from jupiter, where bucketing ran on `expecteddeliverytime` and they described **promised delivery slots**, which really did cluster.
This app buckets on `orderdate` — when the order was *placed* — and orders are placed all day. A booking created at 2:43 PM landed in the 12:30–4 PM gap, belonged to no batch, and disappeared from every batch filter on both pages. On Dispatch, where batches are the primary navigation, that made the order invisible entirely. 40% of the clock was a black hole.
Each window now runs to the start of the next. **Every start hour is unchanged, so the change is strictly additive** — verified exhaustively minute by minute: zero rows move between batches, 570 minutes that previously had no batch now have one, and no minute belongs to two.
If you ever move bucketing back to a promised-delivery field, the clustered windows become correct again — but then re-read the table below first, because that field was already tried and rejected for a different reason.
### Time-field selection (`BATCH_TIME_FIELD`)
Fixed at `'created'` → bucket key is `['orderdate']` (the booking's `createdat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
Note the five `TIME_FIELDS` entries that are now inert: this backend emits only `createdat`, `updatedat` and the SLA estimate, so `acceptedtime` / `starttime` / `arrivaltime` / `pickuptime` / `deliverytime` are always undefined. They're harmless while `selectedTimeField` is a constant — but if the operator-facing dropdown is ever resurrected, five of its eight options would silently bucket nothing.
This is a constant, not state. The operator-facing time-field dropdown and the slot-hour editor have both been **deleted** (they were commented-out dead JSX whose setters nothing called). Don't resurrect either without re-reading the ban below.
**Three fields have been tried here. Two were wrong:**

File diff suppressed because it is too large Load Diff

View File

@@ -328,3 +328,24 @@
animation: none !important;
}
}
/* --------------------------------------------------------------------------
Package rows
--------------------------------------------------------------------------
Each row is a parcel entry on the wire. Bordered rather than card-nested so
a four-package order doesn't read as four separate sections.
-------------------------------------------------------------------------- */
.co-package {
padding: 12px;
border: 1px solid var(--co-border);
border-radius: var(--co-radius);
background: var(--co-surface);
}
.co-root .co-package-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--co-text-3);
}

View File

@@ -113,6 +113,11 @@ const sortLocations = (locations) =>
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
// POST /admin/expressbooking's own service tiers. Kept in step with
// assistant/orderActions.js's SERVICE_OPTIONS — these are the API's values,
// not a UI-invented Standard/Express/Same-Day set.
const SERVICE_OPTION_VALUES = ['Normal', 'Fast', 'Superfast'];
const Createorder1 = () => {
const [inputValue2, setInputValue2] = useState('');
const [inputValue3, setInputValue3] = useState('');
@@ -123,7 +128,17 @@ const Createorder1 = () => {
// reason a persistent, visible message instead.
const [locationsStatus, setLocationsStatus] = useState('loading');
const [collectionamt, setCollectionamt] = useState(0);
const [quantity, setQuantity] = useState(1);
// Delivery tier. Was hardcoded to 'Normal' in the submit payload — the
// operator had no way to book a Fast or Superfast delivery from this page at
// all. These three are the API's own service_option values (not the
// Standard/Express/Same-Day set a generic form would offer).
const [serviceOption, setServiceOption] = useState('Normal');
// A parcel entry has NO quantity field (confirmed against
// express-console-api.md) — "N items" is expressed as N entries, which is
// also how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
// So a package list replaces the single category/weight pair: each row
// becomes its own parcel, and `count` expands to that many identical entries.
const [packages, setPackages] = useState([{ category: '', weight: '', description: '', count: 1 }]);
const navigate = useNavigate();
const [open4, setOpen4] = useState(false);
@@ -315,7 +330,7 @@ const Createorder1 = () => {
deliverycity: dropCust.city || '',
deliverylatitude: Number(dropCust.latitude) || 0,
deliverylongitude: Number(dropCust.longitude) || 0,
service_option: 'Normal',
service_option: serviceOption,
finalprice: +totalCharge.toFixed(2) || 0,
notes: otherinstructions || '',
// The parcel object has no numeric quantity field (confirmed against
@@ -325,11 +340,28 @@ const Createorder1 = () => {
// This previously always sent exactly one parcel regardless of what the
// operator typed into the Quantity field, so it silently got dropped —
// matching "I entered 5, deliveries page shows 1".
parcels: Array.from({ length: Math.max(1, Number(quantity) || 1) }, () => ({
itemcategory: category || 'General',
itemdescription: 'Order',
declaredvalue: +totalCharge.toFixed(2) || 0
}))
// One entry per package, expanded by its count. Falls back to the legacy
// single-category behaviour if the operator never touched the package
// section, so an existing habit still works.
parcels: packages.some((pkg) => pkg.category || pkg.description)
? packages.flatMap((pkg) =>
Array.from({ length: Math.max(1, Number(pkg.count) || 1) }, () => ({
itemcategory: pkg.category || 'General',
itemdescription: [pkg.description, pkg.weight].filter(Boolean).join(' · ') || 'Order',
declaredvalue: +totalCharge.toFixed(2) || 0
}))
)
: // Nothing entered in the package section — fall back to the single
// legacy category chip and one parcel. The old free-text Quantity
// field is gone; a package row's `count` is how N items is expressed
// now, so there is no separate quantity state to read.
[
{
itemcategory: category || 'General',
itemdescription: 'Order',
declaredvalue: +totalCharge.toFixed(2) || 0
}
]
};
if (!arr.tenantid) {
opentoast('Pick a tenant before creating an order', 'warning', 2000);
@@ -1172,22 +1204,131 @@ const Createorder1 = () => {
</Card>
</Grid>
{/* ============================== || Cash Collect & Quantity || ============================== */}
{/* ============================== || Package details || ============================== */}
<Card padding={0} elevation="none" className="co-card">
<SectionHeader
color="#6366f1"
icon={<MdInventory2 size={16} />}
title="Collection & Quantity"
subtitle="Cash to collect on delivery, total items"
title="Package Details"
subtitle="What is being sent, and how much to collect"
action={
<Button
label="Add another package"
variant="secondary"
size="sm"
onClick={() => setPackages((prev) => [...prev, { category: '', weight: '', description: '', count: 1 }])}
/>
}
/>
<VStack gap={2} padding={3}>
{packages.map((pkg, i) => (
// Index keys are safe here: rows are only appended or removed,
// never reordered, and each row is fully controlled.
// eslint-disable-next-line react/no-array-index-key
<VStack key={i} className="co-package" gap={2} padding={0}>
<HStack justify="between" vAlign="center" gap={2}>
<Text className="co-package-label">Package {i + 1}</Text>
{packages.length > 1 && (
<Button
label="Remove"
variant="ghost"
size="sm"
onClick={() => setPackages((prev) => prev.filter((_, j) => j !== i))}
/>
)}
</HStack>
<Grid columns={{ minWidth: 170, repeat: 'fit' }} gap={2}>
<Selector
label="Category"
options={PARCEL_CATEGORIES.map((c) => ({ value: c, label: c }))}
value={pkg.category}
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, category: v } : r)))}
placeholder="Select category"
/>
<Selector
label="Weight"
options={WEIGHT_OPTIONS.map((w) => ({ value: w, label: w }))}
value={pkg.weight}
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, weight: v } : r)))}
placeholder="Select weight"
/>
<TextInput
label="Count"
value={String(pkg.count)}
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, count: v.replace(/\D/g, '') } : r)))}
/>
</Grid>
<TextInput
label="Description"
value={pkg.description}
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, description: v } : r)))}
placeholder="2 meal boxes"
/>
</VStack>
))}
<Divider />
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={2}>
<Selector
label="Delivery type"
options={SERVICE_OPTION_VALUES.map((v) => ({ value: v, label: v }))}
value={serviceOption}
onChange={setServiceOption}
/>
<TextInput label="Cash Collect" value={String(collectionamt)} onChange={(v) => setCollectionamt(v.replace(/[^\d.]/g, ''))} />
<TextInput label="Quantity" value={String(quantity)} onChange={(v) => setQuantity(v.replace(/\D/g, ''))} />
</Grid>
</VStack>
</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. */}
<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}>
<HStack className="co-summary-leg" padding={0} gap={0}>
<span className="co-journey-dot" data-end="pickup" />
<VStack gap={0} padding={0}>
<Text className="co-summary-muted">Pickup</Text>
<Text className="co-summary-leg-text">{pickCust.address || '—'}</Text>
</VStack>
</HStack>
<HStack className="co-summary-leg" padding={0} gap={0}>
<span className="co-journey-dot" data-end="drop" />
<VStack gap={0} padding={0}>
<Text className="co-summary-muted">Drop</Text>
<Text className="co-summary-leg-text">{dropCust.address || '—'}</Text>
</VStack>
</HStack>
<Divider />
<HStack className="co-price-row" padding={0} gap={0}>
<span>Schedule</span>
<b>{selectedtime ? `${dayjs(startdate).format('DD MMM')} · ${selectedtime}` : '—'}</b>
</HStack>
<HStack className="co-price-row" padding={0} gap={0}>
<span>Packages</span>
<b>
{packages.reduce((n, pkg) => n + Math.max(1, Number(pkg.count) || 1), 0)}
{packages[0]?.weight ? ` · ${packages[0].weight}` : ''}
</b>
</HStack>
<HStack className="co-price-row" padding={0} gap={0}>
<span>Service</span>
<b>{serviceOption}</b>
</HStack>
<HStack className="co-price-row" padding={0} gap={0}>
<span>Distance</span>
<b>{showDistance ? `${distance} km${durationMin ? ` · ${durationMin} min` : ''}` : '—'}</b>
</HStack>
<HStack className="co-price-total" padding={0} gap={0}>
<span className="co-price-total-label">Estimated total</span>
<span className="co-price-total-value">{showDistance ? `₹${Number(totalCharge || 0).toFixed(2)}` : '—'}</span>
</HStack>
</VStack>
</Card>
{/* ================================================= || Notes || ================================================= */}
<Card padding={0} elevation="none" className="co-card">
<SectionHeader

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from 'react';
import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import Papa from 'papaparse';
import { SHEET_HEADER_MAP, requiredSheetColumns, normalizeHeader, cleanReceiverName } from 'utils/bulkOrderColumns';
import * as XLSX from 'xlsx';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router';
@@ -606,30 +607,11 @@ const MultipleOrders = () => {
};
// ============================== CSV / XLSX upload ==============================
const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
const headerMap = {
'pickupdate(yyyy-mmm-dd)': 'date',
'sendername*': 'locationname',
'senderphone*': 'locationcontact',
'senderaddress*': 'locationaddress',
'receivername*': 'firstname',
receiverphone: 'contactno',
'receiveralternatephone*': 'altcontactno',
receiverfulladdress: 'address',
// Best-effort — no live sample sheet to confirm these are the tenant's
// actual column headers. Without them deliverycity/deliverypincode in
// the submit payload were always sent blank; if these names are wrong,
// behavior is unchanged from before (still blank), not worse.
receivercity: 'city',
receiverpincode: 'postcode',
receiverlatitude: 'latitude',
receiverlongitude: 'longitude',
'itemdescription*': 'description',
Quantity: 'quantity',
' Collect Cash': 'collectionamt'
};
// The map, the header normaliser and the name cleaner now live in
// utils/bulkOrderColumns.js so Doormile AI's file upload reads the exact same
// sheet this page does. Behaviour here is unchanged — the definitions moved,
// they were not rewritten.
const headerMap = SHEET_HEADER_MAP;
const handleFileDirectUpload = (event) => {
try {
@@ -660,7 +642,7 @@ const MultipleOrders = () => {
}
return newRow;
});
const requiredCols = Object.keys(headerMap).filter((k) => k.trim().endsWith('*'));
const requiredCols = requiredSheetColumns();
const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol)));
if (missingRequired.length > 0) {
opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000);

View File

@@ -63,9 +63,8 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
import StatusTabs from 'components/nearle_components/StatusTabs';
import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
@@ -931,25 +930,6 @@ const Orders = () => {
<PageShell fill>
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live />
{/* Derived from the SAME statusCounts map the tab badges below read,
by mapping over ORDERS_STATUS_TABS rather than a second parallel
grouping — so this row can never disagree with the tabs. */}
<KpiGrid>
{ORDERS_STATUS_TABS.map((t) => {
const Icon = t.icon;
return (
<StatCard
key={t.status}
title={t.label}
value={t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)}
icon={<Icon size={20} />}
color={t.color}
loading={isLoadingGetOrders}
/>
);
})}
</KpiGrid>
<DataCard
fill
tabs={

View File

@@ -58,9 +58,18 @@ export const STATUS_META = {
// "is this a known alias?" rather than silently falling back.
//
// The booking enums come from `GET /admin/bookings` (confirmed live — see
// orders.js). `converted_to_consignment` is deliberately `accepted` and not
// `picked`: it fires when the rider marks pickup COMPLETE, but the operator
// workflow on the Orders page treats everything before hand-off as assigned.
// orders.js). `converted_to_consignment` stays `accepted` HERE and only here:
// this registry renders the badge on the Orders page, whose operator workflow
// treats everything before hand-off as assigned.
//
// The DELIVERIES page classifies the same status as `picked`, in
// `mapBookingStatusToDeliveryStatus` (api.js) — deliberately, and the two are
// not in conflict. Deliveries tracks the rider's engagement, and
// doormile-flow.md §5 is explicit that `pickup-complete` is what converts a
// booking into a consignment, so on that page the parcel is in the rider's
// hands. Orders tracks the operator's action, where it is still just assigned.
// Same booking, two questions, two answers. Don't "fix" one to match the other
// without re-reading root CLAUDE.md on why the two taxonomies exist.
export const STATUS_ALIASES = {
pending_pickup: 'pending',
pending_assignment: 'pending',

View File

@@ -9,13 +9,25 @@ import { parseDoormileTimestamp } from './doormileTimestamp';
// to import this (dispatch/CLAUDE.md is explicit that folder isn't touched
// casually); this is purely additive.
//
// Half-open [startHour, endHour) ranges in LOCAL time. Gaps (8–9am,
// 12:30pm–4pm, after 7pm) are intentional — a row in a gap belongs to no
// batch, not the nearest one.
// Half-open [startHour, endHour) ranges in LOCAL time, and they COVER THE
// WHOLE DAY. That is the point.
//
// They used to be 0–8, 9–12.5 and 16–19 — 14.5 of 24 hours, with gaps at
// 8–9am, 12:30–4pm and after 7pm. Those windows came from jupiter, where
// bucketing ran on `expecteddeliverytime` and they described PROMISED DELIVERY
// SLOTS. This app buckets on `orderdate` (when the order was placed) instead,
// and orders are placed all day: an order created at 2:43pm fell in the
// 12:30–4pm gap, belonged to no batch, and vanished from every batch filter on
// both the Deliveries and Dispatch pages. 40% of the clock was a black hole.
//
// Each window now runs to the start of the next, so every row lands in exactly
// one batch. Every START hour is unchanged, which makes this strictly additive:
// no row that already had a batch moves to a different one — only rows that had
// none gain one.
export const BATCHES = [
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 19 }
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 9 },
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 16 },
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 24 }
];
export const getBatchForHour = (h) => {
@@ -25,6 +37,18 @@ export const getBatchForHour = (h) => {
return null;
};
// Human-readable window, e.g. "12 AM–9 AM" / "After 4 PM". Kept here so the
// pages' pickers describe the same ranges they filter by.
const clock = (h) => {
const whole = Math.floor(h);
const mins = Math.round((h - whole) * 60);
const suffix = whole >= 12 ? 'PM' : 'AM';
const hour12 = whole % 12 === 0 ? 12 : whole % 12;
return mins ? `${hour12}:${String(mins).padStart(2, '0')} ${suffix}` : `${hour12} ${suffix}`;
};
export const batchRangeLabel = (b) => (b.endHour >= 24 ? `After ${clock(b.startHour)}` : `${clock(b.startHour)}–${clock(b.endHour)}`);
// The one field batch bucketing uses across the app: a booking's `orderdate`
// (== createdat — see fetchDeliveries in api.js). `assigntime` and
// `expecteddeliverytime` were both tried and rejected — see dispatch/

View File

@@ -0,0 +1,150 @@
// ==============================|| Bulk-order sheet columns ||============================== //
//
// The single definition of what a bulk-order spreadsheet may look like. Two
// consumers share it:
//
// • pages/nearle/orders/multipleOrders.js — the console's bulk-upload page,
// where this map originated.
// • pages/nearle/assistant/bulkFile.js — Doormile AI's file upload.
//
// It lives here so a sheet that uploads on the page also uploads in the bot,
// permanently. Copying it into the assistant was the alternative and was
// rejected for the obvious reason: five pages once carried private STATUS_META
// copies that drifted into disagreeing about the same order's label.
//
// SHEET_HEADER_MAP is reproduced from the page byte for byte, including the `*`
// suffix convention (a starred header is required) and the note about which
// mappings were never confirmed against a live tenant sheet. Do not "tidy" the
// keys — they are the tenant's actual column titles, spaces and all.
export const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
// A receiver name arrives prefixed with the sheet's own row numbering
// ("12. Ravi Kumar") often enough that stripping it is part of parsing.
export const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
export const SHEET_HEADER_MAP = {
'pickupdate(yyyy-mmm-dd)': 'date',
'sendername*': 'locationname',
'senderphone*': 'locationcontact',
'senderaddress*': 'locationaddress',
'receivername*': 'firstname',
receiverphone: 'contactno',
'receiveralternatephone*': 'altcontactno',
receiverfulladdress: 'address',
// Best-effort — no live sample sheet to confirm these are the tenant's
// actual column headers. Without them deliverycity/deliverypincode in
// the submit payload were always sent blank; if these names are wrong,
// behavior is unchanged from before (still blank), not worse.
receivercity: 'city',
receiverpincode: 'postcode',
receiverlatitude: 'latitude',
receiverlongitude: 'longitude',
'itemdescription*': 'description',
Quantity: 'quantity',
' Collect Cash': 'collectionamt'
};
// Starred headers are the ones the page reports as missing.
export const requiredSheetColumns = () => Object.keys(SHEET_HEADER_MAP).filter((k) => k.trim().endsWith('*'));
// ---- assistant-side translation ---------------------------------------------
//
// The page consumes the internal names above (`firstname`, `contactno`, …)
// because its row editors were built on them. The assistant's rows are shaped
// like a booking payload instead, so the two are bridged here rather than in
// either consumer.
const INTERNAL_TO_ROW = {
firstname: 'customer_name',
contactno: 'customer_phone',
address: 'deliveryaddress',
postcode: 'deliverypincode',
city: 'deliverycity',
latitude: 'deliverylatitude',
longitude: 'deliverylongitude',
description: 'itemdescription',
quantity: 'quantity'
};
// Headers someone typing their own sheet actually writes. Accepted in addition
// to the tenant's official titles, never instead of them.
//
// `collectionamt` (the sheet's "Collect Cash") is deliberately NOT treated as a
// price: it is cash to collect from the recipient, while `finalprice` is what
// the delivery costs. Mapping one onto the other would quietly bill the wrong
// number on every row.
const PLAIN_ALIASES = {
customer_name: ['name', 'customername', 'customer', 'recipient', 'recipientname', 'receiver', 'to'],
customer_phone: ['phone', 'mobile', 'phonenumber', 'mobilenumber', 'customerphone', 'contact', 'recipientphone'],
deliveryaddress: ['address', 'deliveryaddress', 'dropaddress', 'fulladdress', 'destination'],
deliverypincode: ['pincode', 'pin', 'postcode', 'zip', 'zipcode', 'deliverypincode'],
deliverycity: ['city', 'town', 'deliverycity'],
deliverylatitude: ['latitude', 'lat', 'deliverylatitude'],
deliverylongitude: ['longitude', 'lng', 'lon', 'deliverylongitude'],
itemdescription: ['description', 'item', 'itemdescription', 'contents', 'goods', 'particulars'],
itemcategory: ['category', 'itemcategory', 'type'],
quantity: ['quantity', 'qty', 'parcels', 'pieces', 'count'],
finalprice: ['price', 'amount', 'charge', 'deliverycharge', 'finalprice', 'rate', 'fare'],
weight: ['weight', 'kg', 'kgs']
};
// normalised header → assistant row field. Built once; the tenant's official
// titles win over a plain alias if a sheet somehow carries both.
const ROW_FIELD_BY_HEADER = (() => {
const out = {};
Object.entries(PLAIN_ALIASES).forEach(([field, headers]) => {
headers.forEach((h) => {
out[normalizeHeader(h)] = field;
});
});
Object.entries(SHEET_HEADER_MAP).forEach(([sheetHeader, internal]) => {
const field = INTERNAL_TO_ROW[internal];
if (!field) return;
out[normalizeHeader(sheetHeader)] = field;
out[normalizeHeader(sheetHeader).replace(/\*+$/, '')] = field;
});
return out;
})();
// The `*` in a sheet title is a "this one is required" annotation the tenant
// types, not part of the column's name — so `Receiver Phone*` and
// `ReceiverPhone` are the same column and both must resolve. Only the
// assistant's lookup is star-tolerant; normalizeHeader itself is untouched,
// because multipleOrders.js derives its missing-required warning from the star
// and loosening that would change the page's behaviour.
const unstar = (h) => normalizeHeader(h).replace(/\*+$/, '');
export const rowFieldForHeader = (header) => ROW_FIELD_BY_HEADER[normalizeHeader(header)] || ROW_FIELD_BY_HEADER[unstar(header)] || null;
// Every field the assistant can fill from a sheet, for the downloadable template.
export const TEMPLATE_HEADERS = [
'Receiver Name*',
'Receiver Phone*',
'Receiver Full Address*',
'Receiver Pincode*',
'Receiver City',
'Item Description*',
'Quantity',
'Price'
];
// ---- one sheet row → one assistant row --------------------------------------
//
// Unrecognised columns are RETURNED, not dropped silently: an operator whose
// price column is titled something unexpected needs to be told it was ignored,
// or they'll submit 200 orders priced from a column that was never read.
export const mapSheetRow = (raw) => {
const row = {};
const ignored = [];
Object.entries(raw || {}).forEach(([header, value]) => {
const field = rowFieldForHeader(header);
if (!field) {
if (String(header || '').trim() && String(value ?? '').trim()) ignored.push(String(header).trim());
return;
}
row[field] = field === 'customer_name' ? cleanReceiverName(value) : value;
});
return { row, ignored };
};

View File

@@ -31,7 +31,21 @@ doormileAxios.interceptors.response.use(
localStorage.clear();
window.location.replace('/login');
}
return Promise.reject(error.response?.data || error);
// Reject with the server's own body (callers read `err.message`), but keep
// the HTTP status reachable as `err.httpStatus`.
//
// Without this the status is lost entirely: every caller's `err.response`
// is undefined, so a 404 and a 500 and a validation failure all collapse
// into the same generic "something went wrong". The assistant's write
// paths need to tell "that route doesn't exist" apart from "your body was
// wrong", and that distinction only exists in the status code.
//
// Non-enumerable so nothing that spreads or JSON-stringifies the body
// starts carrying it around.
const body = error.response?.data;
if (body === undefined || body === null) return Promise.reject(error);
const payload = typeof body === 'object' ? body : { message: String(body) };
return Promise.reject(Object.defineProperty(payload, 'httpStatus', { value: error.response?.status, enumerable: false }));
}
);