updates on the ui changes and the ai bot implementation

This commit is contained in:
2026-08-19 11:19:51 +05:30
parent 5284fc75bc
commit bdb21766f2
27 changed files with 2607 additions and 278 deletions

View File

@@ -56,7 +56,11 @@ import { Divider } from '@astryxdesign/core/Divider';
// ---------------------------------------------------------------------------
const FILL_CLASS = 'dt-page-fill';
export function PageShell({ children, gap = 6, fill = false }) {
// `className` is forwarded so a page can scope its own stylesheet to the
// 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 }) {
return (
<>
{fill && (
@@ -69,7 +73,12 @@ export function PageShell({ children, gap = 6, fill = false }) {
}
`}</style>
)}
<VStack gap={gap} padding={0} className={fill ? FILL_CLASS : undefined} style={fill ? { flex: 1, minHeight: 0 } : undefined}>
<VStack
gap={gap}
padding={0}
className={[fill ? FILL_CLASS : null, className].filter(Boolean).join(' ') || undefined}
style={fill ? { flex: 1, minHeight: 0 } : undefined}
>
{children}
</VStack>
</>
@@ -79,7 +88,8 @@ export function PageShell({ children, gap = 6, fill = false }) {
PageShell.propTypes = {
children: PropTypes.node,
gap: PropTypes.number,
fill: PropTypes.bool
fill: PropTypes.bool,
className: PropTypes.string
};
// ---------------------------------------------------------------------------

View File

@@ -9,7 +9,7 @@ const NOTIFICATION_STYLE = {
left: '50%', // Center horizontally
transform: 'translateX(-50%)', // Offset to truly center
padding: '12px 40px',
borderRadius: '8px',
borderRadius: '5px',
color: 'white',
fontWeight: '500',
fontSize: '14px',

View File

@@ -40,7 +40,7 @@ body {
*::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.16);
border-radius: 8px;
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}

View File

@@ -33,6 +33,39 @@ export const logoutAdmin = () => {
localStorage.removeItem(DOORMILE_USER_KEY);
};
// Drains a paginated list endpoint instead of taking whatever the server's
// default page size happens to be.
//
// GET /admin/customers defaults to `pagesize: 20` — confirmed live against an
// account with 37 customers, which came back as
// { pageno: 1, pagesize: 20, pages: 2, total: 37 }. Every caller of
// getAdminCustomers was therefore seeing 20 of 37 and had no way to know:
// the Customers page reported "Total Customers 20", and the customer lookups
// on the orders pages silently missed anyone on page 2.
//
// (express-console-api.md claims the default is 500. It is not, at least not
// for this endpoint. Trust the envelope, not the doc.)
const PAGE_SIZE = 100; // the doc's own example value — safer than assuming the 1000 cap holds
const MAX_PAGES = 12;
const fetchAllPages = async (path) => {
const first = await doormileAxios.get(`${path}${buildQuery({ pageno: 1, pagesize: PAGE_SIZE })}`);
const body = first.data || {};
const rows = body.data || [];
const total = Number(body.total);
if (!Number.isFinite(total) || rows.length >= total) return rows;
const pageCount = Math.min(Math.ceil(total / PAGE_SIZE), MAX_PAGES);
for (let page = 2; page <= pageCount; page += 1) {
// eslint-disable-next-line no-await-in-loop
const next = await doormileAxios.get(`${path}${buildQuery({ pageno: page, pagesize: PAGE_SIZE })}`);
const more = next.data?.data || [];
if (!more.length) break;
rows.push(...more);
}
return rows;
};
// ==============================|| Dashboard, profile, reports ||============================== //
export const getDashboard = async () => {
@@ -162,10 +195,7 @@ export const updateTenantLocation = async (locationId, data) => {
// ==============================|| Tenant customers (a client's own end customers) ||============================== //
export const getTenantCustomers = async () => {
const response = await doormileAxios.get('/admin/tenantcustomers');
return response.data.data;
};
export const getTenantCustomers = async () => fetchAllPages('/admin/tenantcustomers');
export const createTenantCustomer = async (data) => {
const response = await doormileAxios.post('/admin/tenantcustomers', data);
@@ -189,9 +219,21 @@ export const deleteTenantCustomer = async (id) => {
// ==============================|| B2C app customers ||============================== //
export const getAdminCustomers = async () => {
const response = await doormileAxios.get('/admin/customers');
return response.data.data;
export const getAdminCustomers = async () => fetchAllPages('/admin/customers');
// POST /admin/customers.
//
// express-console-api.md lists this resource as GET + PATCH only, and states
// that a customer there comes into existence through a booking. Added on
// explicit instruction to create into the store the Customers page actually
// reads. If the route genuinely does not exist the call returns 404/405, and
// the assistant surfaces that verbatim with the endpoint name rather than
// failing silently — so an unsupported route is immediately visible.
//
// Body mirrors this resource's own GET shape.
export const createAdminCustomer = async (data) => {
const response = await doormileAxios.post('/admin/customers', data);
return response.data;
};
export const updateAdminCustomer = async (id, data) => {

View File

@@ -112,6 +112,19 @@ If you add a new intent whose trigger words could plausibly appear in an unrelat
---
### Customer creation writes to `/admin/customers`
`executeCreateCustomer` posts to `/admin/customers` — the same resource the Customers page reads — so a created customer appears there straight away.
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).
Two things to know if this ever needs revisiting:
- `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.
---
## 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.

View File

@@ -79,7 +79,7 @@
overflow: hidden;
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 16px;
border-radius: 5px;
box-shadow: var(--dai-shadow);
transform: translateX(calc(100% + var(--dai-inset) * 2));
opacity: 0;
@@ -202,6 +202,9 @@
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* Never scroll sideways. A single wide child (the create form's name row)
used to push the whole message area horizontally. */
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--dai-border-strong) transparent;
@@ -270,7 +273,7 @@
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 12px;
border-radius: 5px;
cursor: pointer;
transition:
background-color 140ms ease,
@@ -299,7 +302,7 @@
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
border-radius: 5px;
background: var(--dai-surface-hover);
color: var(--dai-text-secondary);
}
@@ -346,7 +349,7 @@
.dai-link:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
border-radius: 4px;
border-radius: 5px;
}
/* --------------------------------------------------------------------------
@@ -376,8 +379,8 @@
max-width: 82%;
margin-left: auto;
padding: 8px 12px;
border-radius: 14px;
border-bottom-right-radius: 6px;
border-radius: 5px;
border-bottom-right-radius: 5px;
font-size: 13.5px;
line-height: 1.45;
color: var(--dai-accent-contrast);
@@ -436,7 +439,7 @@
.dai-root .dai-stat {
padding: 9px 10px;
border: 1px solid var(--dai-border);
border-radius: 10px;
border-radius: 5px;
background: var(--dai-surface-alt);
}
@@ -530,7 +533,7 @@
.dai-root .dai-state {
padding: 12px;
border: 1px solid var(--dai-border);
border-radius: 12px;
border-radius: 5px;
background: var(--dai-surface-alt);
}
@@ -552,7 +555,7 @@
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border-radius: 5px;
flex: 0 0 auto;
}
@@ -608,7 +611,7 @@
.dai-root .dai-composer {
border: 1px solid var(--dai-border-strong);
border-radius: 16px;
border-radius: 5px;
background: var(--dai-surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 8px 8px 6px 12px;
@@ -693,7 +696,7 @@
width: 32px;
height: 32px;
padding: 0;
border-radius: 9px;
border-radius: 5px;
border: 1px solid transparent;
background: transparent;
color: #4f46e5;
@@ -774,3 +777,198 @@
transition: none !important;
}
}
/* --------------------------------------------------------------------------
Write-action confirm card
-------------------------------------------------------------------------- */
.dai-root .dai-action {
padding: 10px 11px;
border: 1px solid var(--dai-border-strong);
border-radius: 5px;
background: var(--dai-surface-alt);
}
.dai-root .dai-action-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 5px;
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
--------------------------------------------------------------------------
Flex items default to `min-width: auto`, which means they refuse to shrink
below their content's intrinsic width. Two side-by-side TextInputs in the
create-customer form therefore pushed the message column wider than the
panel and produced a horizontal scrollbar. Every link in the chain from the
scroll region down to the field has to opt out of that.
-------------------------------------------------------------------------- */
.dai-root .dai-thread,
.dai-root .dai-msg,
.dai-root .dai-msg-row,
.dai-root .dai-action,
.dai-root .dai-form {
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
panel sideways again. */
.dai-root .dai-form-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
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 {
width: 100%;
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. */
.dai-root .dai-msg-foot {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
width: 100%;
min-width: 0;
}
.dai-root .dai-msg-foot .dai-msg-footer {
flex: 0 1 auto;
min-width: 0;
}
/* 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 {
width: 100%;
min-width: 0;
border: 1px solid var(--dai-border);
border-radius: 5px;
background: var(--dai-surface-alt);
overflow: hidden;
}
.dai-root .dai-list-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
border-bottom: 1px solid var(--dai-border);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
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;
align-items: baseline;
gap: 2px 8px;
padding: 6px 10px;
font-size: 12.5px;
line-height: 1.4;
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;
font-size: 11px;
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
there isn't enough room for both. */
.dai-root .dai-list-label {
flex: 0 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.dai-root .dai-list-meta {
flex: 1 1 auto;
min-width: 0;
text-align: right;
font-size: 11.5px;
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;
}

View File

@@ -0,0 +1,193 @@
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

@@ -9,7 +9,9 @@ import { Text } from '@astryxdesign/core/Text';
import { IconButton } from '@astryxdesign/core/IconButton';
import { ChatToolCalls } from '@astryxdesign/core/Chat';
import { Spark, Metric, StatGrid, StateBlock } from './AIParts';
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
import AICustomerForm from './AICustomerForm';
import AIOrderForm from './AIOrderForm';
// ==============================|| Doormile AI — a single turn ||============================== //
//
@@ -30,7 +32,7 @@ const UserMessage = ({ text }) => (
UserMessage.propTypes = { text: PropTypes.string.isRequired };
const AssistantMessage = ({ message, onCopy, onAsk }) => {
const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction }) => {
const [showSources, setShowSources] = useState(false);
const sourceCount = message.sourceCalls?.length || 0;
@@ -67,6 +69,8 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
<StatGrid stats={message.stats} />
{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>}
</VStack>
)}
@@ -75,7 +79,7 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
that it's available, not that it's announced on every turn. */}
{sourceCount > 0 && (
<VStack gap={1} padding={0}>
<HStack gap={1} padding={0} vAlign="center">
<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})
@@ -90,6 +94,31 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
</VStack>
)}
{/* ---- Write gate ---------------------------------------------------
Real inputs, not a parsed sentence. The form shows every value that
will be sent and submits only on Create. A form rehydrated from
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)}
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)}
onCancel={() => onCancelAction(message)}
/>
)}
{message.followUps?.length > 0 && (
<VStack gap={1} padding={0}>
{message.followUps.map((q) => (
@@ -107,16 +136,24 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
AssistantMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired
onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired
};
const AIMessage = ({ message, onCopy, onAsk }) =>
message.sender === 'user' ? <UserMessage text={message.text} /> : <AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} />;
const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction }) =>
message.sender === 'user' ? (
<UserMessage text={message.text} />
) : (
<AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} onSubmitForm={onSubmitForm} onCancelAction={onCancelAction} />
);
AIMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired
onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired
};
export default AIMessage;

View File

@@ -0,0 +1,276 @@
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

@@ -15,6 +15,8 @@ import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
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 { getPageContext } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome';
@@ -46,7 +48,12 @@ const loadHistory = () => {
try {
const raw = localStorage.getItem(HISTORY_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
if (!Array.isArray(parsed)) return [];
// Any write proposal that was still awaiting confirmation when the panel
// 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));
} catch {
return [];
}
@@ -149,6 +156,7 @@ const AIPanel = ({ isOpen, onClose }) => {
if (result) {
setContext({ lastIntentId: result.intentId, lastParams: result.params });
push({
form: result.form,
sender: 'assistant',
text: result.headline,
detail: result.detail,
@@ -203,6 +211,89 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsPinned(true);
};
const setFormStatus = (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 });
// ---- 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) }))
},
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
});
}
} 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.`
});
}
};
const cancelAction = (message) => {
if (message.form?.status !== 'open') return;
setFormStatus(message.id, { status: 'cancelled' });
};
if (!isMounted) return null;
const hasThread = messages.length > 0;
@@ -263,7 +354,14 @@ const AIPanel = ({ isOpen, onClose }) => {
{hasThread ? (
<VStack className="dai-thread" gap={4} padding={0}>
{messages.map((m) => (
<AIMessage key={m.id} message={m} onCopy={copyMessage} onAsk={ask} />
<AIMessage
key={m.id}
message={m}
onCopy={copyMessage}
onAsk={ask}
onSubmitForm={submitForm}
onCancelAction={cancelAction}
/>
))}
{isSending && (
<VStack className="dai-msg" gap={1.5} padding={0}>

View File

@@ -98,6 +98,38 @@ const STATE_ICONS = {
info: <InfoCircleOutlined />
};
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
// left an operator unable to see the rest of an answer at all — the body
// scrolls past ~12 rows so a long list can't stretch the panel.
export const AnswerList = ({ title, items, numbered = true }) => {
if (!items?.length) return null;
return (
<VStack className="dai-list" gap={0} padding={0}>
<HStack className="dai-list-head" padding={0} gap={0}>
<span>{title}</span>
<span>{items.length}</span>
</HStack>
<VStack className="dai-list-body" gap={0} padding={0} role="list">
{items.map((item, i) => (
<HStack className="dai-list-item" key={`${item.label}-${i}`} padding={0} gap={0} role="listitem">
{/* Numbering makes sense for "the 14 tenants"; it is noise on a
field/value dump like a submitted payload. */}
{numbered ? <span className="dai-list-index">{i + 1}</span> : null}
<span className="dai-list-label">{item.label}</span>
{item.meta ? <span className="dai-list-meta">{item.meta}</span> : null}
</HStack>
))}
</VStack>
</VStack>
);
};
AnswerList.propTypes = {
title: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string.isRequired, meta: PropTypes.string })),
numbered: PropTypes.bool
};
// Shared shell for the error / no-data states, so both read as the same kind
// of thing rather than two separately-invented blocks.
export const StateBlock = ({ iconKey, tone, title, body, action }) => (

View File

@@ -1,4 +1,17 @@
import { LuClock3, LuPackage, LuBike, LuTruck, LuBuilding2, LuCircleDot, LuBanknote, LuUsers, LuLayers, LuTimerOff } from 'react-icons/lu';
import {
LuClock3,
LuPackage,
LuBike,
LuTruck,
LuBuilding2,
LuCircleDot,
LuBanknote,
LuUsers,
LuLayers,
LuTimerOff,
LuUserPlus,
LuPackagePlus
} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
//
@@ -18,14 +31,16 @@ const ORDERS = {
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuBanknote, text: 'Total revenue today' }
{ icon: LuPackagePlus, text: 'Create an order' }
],
more: [
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuCircleDot, text: 'How many pending orders today?' },
{ icon: LuPackage, text: 'How many cancelled orders today?' },
{ icon: LuPackage, text: 'How many delivered orders today?' },
{ icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuClock3, text: 'Orders today vs yesterday' }
{ icon: LuClock3, text: 'Orders today vs yesterday' },
{ icon: LuUserPlus, text: 'Create a customer' }
]
};
@@ -126,7 +141,19 @@ const ROUTES = [
['/doormile/hubs', HUBS],
['/doormile/tenants', TENANTS],
['/doormile/reports', REPORTS],
['/doormile/customers', { ...DEFAULT_CONTEXT, label: 'Customers' }],
[
'/doormile/customers',
{
label: 'Customers',
suggestions: [
{ icon: LuUserPlus, text: 'Create a customer' },
{ icon: LuUsers, text: 'How many customers do we have?' },
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' }
],
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
}
],
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]

View File

@@ -0,0 +1,306 @@
# Doormile AI — RAG implementation plan
Retrieval-augmented routing and document Q&A, backed by a local ChromaDB.
**Status:** plan only. Nothing here is built.
**Prerequisite decision:** where the sidecar runs (see §11).
---
## 1. What this changes, and what it deliberately does not
### The problem being solved
The assistant routes questions with **34 hand-written regex matchers**. That has a hard vocabulary ceiling, and we hit it repeatedly:
| Phrase | What went wrong |
|---|---|
| "cancellation rate" | `\bcancel(led)?\b` doesn't match "cancellation" — the `\b` fails on the following `l` |
| "8494948494" (a bare reply) | matched no intent at all; the reply was dropped |
| "orders per day this week" | `weekOrders` answered with one number and silently dropped "per day" |
| "delivered orders for Acme" | tenant discarded until `orderQuery` was built |
Every one of those was a regex fix. The next ten will be too. **RAG removes the ceiling** — a new phrasing works because it's *semantically near* an example, not because someone wrote a pattern for it.
### What RAG must NOT do here
`assistant/CLAUDE.md` §2 records RAG being considered and rejected, and that reasoning stands **for the data**:
> "this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions."
A vector store is a snapshot. "How many orders today" changes every minute. Answering it from embeddings means answering from whenever we last indexed.
**So: RAG selects the question. The existing API layer still produces every number.**
```
question ──► embed ──► Chroma ──► top-k intent examples ──► intentId + slots + confidence
│
▼
the SAME deterministic run() executes
│
▼
real API call ──► real number
```
The "never fabricate a number" guarantee survives untouched. Nothing in the vector store ever becomes a figure the operator reads.
### Why this is not blocked by the §2 decision
§2's blocker was: *an LLM call needs an API key, and a static CRA build has nowhere to put one.*
This plan uses a **local embedding model running in Node** (`@xenova/transformers`). No key, no network, no per-call cost. The blocker doesn't apply. If we later want a hosted embedding model or a generation step, §2 applies again and needs its own decision.
---
## 2. Architecture
```
docker-compose.yml
├── chroma chromadb/chroma:latest :8000 persistent volume ./.chroma
└── ai-sidecar node:20 :8787 services/ai
services/ai/
├── package.json own deps — NOT added to the CRA package.json
├── index.js Express app: /health, /route, /ask, /reindex
├── embed.js MiniLM via @xenova/transformers, cached in-process
├── collections.js Chroma client, collection get-or-create
├── seed/
│ ├── intents.js builds intent_examples from the phrasing catalog
│ ├── docs.js chunks the markdown docs
│ └── phrasings.json ~20 example phrasings per intent (hand-written)
├── eval.js routing accuracy vs the regex baseline
└── README.md how to run it locally
```
**The CRA app gains no new dependencies.** The sidecar is a separate package with its own `package.json`, so `react-scripts`, the webpack config and the `resolutions` block in the root `package.json` are untouched (root `CLAUDE.md` §4.3).
### Ports and env
| | |
|---|---|
| Chroma | `http://localhost:8000` |
| Sidecar | `http://localhost:8787` |
| CRA reads | `REACT_APP_AI_URL` (absent → RAG disabled, regex only) |
`REACT_APP_AI_URL` being unset must be a supported state, not a broken one — that is what keeps the app deployable exactly as it is today.
---
## 3. Data model
### Collection `intent_examples`
One vector per example phrasing. ~34 intents × ~20 phrasings ≈ **700 vectors**. Trivially small; Chroma handles it in memory.
```js
{
id: 'statusBreakdown::07',
document: 'how many orders got cancelled this week',
metadata: {
intentId: 'statusBreakdown',
domain: 'orders', // orders | riders | tenants | hubs | vehicles | ops | write
isWrite: false, // write intents need a higher bar — see §6
slotsHint: 'status,range' // documentation only; slots still parsed deterministically
}
}
```
### Collection `console_docs`
Chunked markdown for genuine document Q&A — "what is CityGate", "why does dispatch reconcile before commit".
```js
{
id: 'express-console-api.md::conventions::2',
document: '<chunk text>',
metadata: {
source: 'express-console-api.md',
heading: 'Conventions across every endpoint',
updatedAt: '2026-08-19'
}
}
```
**Chunking:** split on markdown headings, then hard-wrap at ~800 characters with ~100 characters of overlap. Heading path is prepended to each chunk so a chunk carries its own context.
**Sources to index:** `express-console-api.md`, root `CLAUDE.md`, `src/pages/api/CLAUDE.md`, `src/pages/nearle/assistant/CLAUDE.md`, `src/pages/nearle/dispatch/CLAUDE.md`, `src/pages/nearle/orders/CLAUDE.md`, `ROADMAP.md`.
---
## 4. The routing contract
### `POST /route`
```jsonc
// request
{ "text": "how many orders got cancelled this week" }
// response
{
"intentId": "statusBreakdown",
"confidence": "high", // high | medium | low
"score": 0.91, // cosine similarity of top-1
"margin": 0.19, // top-1 minus top-2 — the honest signal
"alternatives": [
{ "intentId": "orderRate", "score": 0.72 }
],
"matchedExample": "how many orders got cancelled this week"
}
```
### How confidence is derived — and why margin, not score
Cosine similarity is **not** a probability of correctness. 0.87 does not mean "87% likely right". What actually carries information is the **margin** between the best and second-best match:
| Condition | Confidence | Bot behaviour |
|---|---|---|
| `score ≥ 0.75` and `margin ≥ 0.10` | **high** | route and answer |
| `score ≥ 0.60` and `margin ≥ 0.05` | **medium** | route, and name the interpretation in the answer |
| otherwise | **low** | **don't guess** — offer the top 2–3 as buttons |
Thresholds are starting values, tuned in phase 7 against the eval corpus.
**What the operator sees:** *"Matched: orders by status · high confidence"* — never *"87% sure the answer is 42."* The answer's correctness comes from deterministic execution; the score only describes how sure we are which question was asked. Conflating the two would be exactly the kind of false precision this bot has avoided all along.
### `POST /ask` (docs)
```jsonc
{ "text": "what is CityGate" }
→ { "chunks": [ { "text": "...", "source": "express-console-api.md",
"heading": "Conventions", "score": 0.88 } ] }
```
**No generation step.** It returns the source passages with attribution and the panel renders them. Summarising them into new prose would require an LLM — which is §2's blocked decision — and would also let a paraphrase drift from what the doc says.
---
## 5. Embedding model
**`Xenova/all-MiniLM-L6-v2`** via `@xenova/transformers`.
| | |
|---|---|
| Size | ~25 MB, downloaded once, cached on disk |
| Dimensions | 384 |
| Runs | in-process in Node — no key, no network, no per-call cost |
| Speed | ~5 ms per short query on CPU |
Good enough for short operator phrases, which is the whole workload. Upgrade path if the eval shows it's not: `text-embedding-3-small`. That needs a key, which re-opens §2 — do not do it silently.
**Important:** queries and documents must be embedded with the *same* model and the same normalisation. A model change means a full re-seed; `seed.js` writes the model name into collection metadata so a mismatch is detectable rather than silently wrong.
---
## 6. Integration with the bot
`answerQuestion(text, context)` gains a routing step **in front of** the existing matcher:
```js
// 1. try semantic routing, but never let it break the bot
const routed = await routeViaRag(text).catch(() => null);
// 2. high/medium confidence → run that intent's own run()
if (routed && routed.confidence !== 'low') {
const intent = INTENTS_BY_ID[routed.intentId];
const params = intent?.match(text) ?? deriveSlots(text, routed);
const result = params && (await intent.run(params));
if (result) return { ...result, routing: routed };
}
// 3. fall back to the 34 regex intents, exactly as today
return matchAndRun(text);
```
### Non-negotiables
**The regex matcher stays.** It is the fallback when the sidecar is down, `REACT_APP_AI_URL` is unset, or confidence is low. Today's behaviour is the floor — RAG can only improve it, never remove it.
**Slots stay deterministic.** `rangeFromWords`, `statusFromWords`, `orderStatusGroups`, entity resolution all keep their jobs. Embeddings are good at *"what kind of question is this"* and bad at *"the 14th"*. RAG picks the intent; parsing extracts the values.
**Write intents need a higher bar.** `createCustomer` and `createOrder` must require **high** confidence AND an explicit verb match. A semantic near-miss must never open a write form. The `isWrite` flag in metadata exists for exactly this check.
**Timeout.** 400 ms budget on `/route`; past that, fall through to regex. The bot must never feel slower because a container is cold.
---
## 7. Evaluation — how we prove it is better
The existing **40-phrase routing corpus** (`scratchpad/route_probe.mjs`) becomes the regression baseline. But it is not a fair test: those phrases were written *for* the regex matcher and it scores 40/40 on them.
The real test is a **held-out set of ~60 phrasings neither implementation was tuned against**, written by someone who hasn't read the matchers. Operator language, not developer language: *"anything stuck?"*, *"what's late"*, *"how'd Kumar do"*.
`eval.js` reports:
| Metric | Meaning |
|---|---|
| Routing accuracy | correct `intentId` — the headline number |
| Coverage | % answered at all (regex's weakness: silent no-match) |
| False routes | wrong intent answered confidently — **the number that matters most** |
| Write safety | zero write intents triggered by non-write phrasings |
| p50 / p95 latency | must stay under the 400 ms budget |
**Ship criterion:** RAG beats regex on accuracy *and* coverage on the held-out set, with **zero** false write routes. A false route is worse than a no-match — it's the same class of failure as the "19 assigned vs 0" bug.
---
## 8. Phases
| # | Deliverable | Acceptance | Effort |
|---|---|---|---|
| 0 | **Sidecar hosting decision** (§11) | answered | — |
| 1 | `docker-compose.yml`, Chroma up, Express `/health` | `curl :8787/health` → ok, Chroma reachable | 0.5 d |
| 2 | `embed.js`, model cached, round-trip verified | same text → identical vector twice | 0.5 d |
| 3 | `phrasings.json` — 20 per intent × 34 | seeded, count verified | 1.5 d |
| 4 | `POST /route` with confidence + margin | returns correct intent for 20 hand checks | 0.5 d |
| 5 | Bot integration + fallback + 400 ms timeout | **kill the sidecar mid-session → bot still answers** | 0.5 d |
| 6 | `console_docs` + `POST /ask` + panel rendering | "what is CityGate" returns the right passage | 1 d |
| 7 | `eval.js` + held-out set + threshold tuning | report produced, thresholds fixed from data | 1 d |
**Total ≈ 5.5 days.** Phases 1–5 (≈3.5 d) deliver the whole routing win; 6–7 add docs and proof.
---
## 9. Operations
- **`npm run seed:ai`** rebuilds both collections from scratch. Idempotent.
- **Docs drift silently.** Re-seed on any change to an indexed markdown file — a CI step, or a pre-commit hook. A stale doc answer is worse than none, because it looks authoritative.
- **Chroma persistence** is a bind-mounted `./.chroma` volume. Add to `.gitignore`.
- **Model cache** likewise (`.cache/transformers`).
- **Nothing in the sidecar touches the Doormile API.** It only routes text. All data access stays in the browser through the existing typed functions, which keeps the tenant scoping and the bearer token exactly where they are today.
---
## 10. Risks
| Risk | Mitigation |
|---|---|
| **First backend in the project** | Dev-only to start. Deploying it is a separate decision with real ops cost. |
| Sidecar down → bot dead | Regex fallback + timeout. Tested explicitly in phase 5. |
| Semantic near-miss opens a write form | `isWrite` requires high confidence **and** verb match. Zero-tolerance metric in eval. |
| Local model too weak on short phrases | Measured in phase 7. Upgrade path exists but re-opens §2. |
| Docs go stale | Re-seed in CI. |
| Phrasing catalog becomes a second matcher to maintain | It is data, not code — and unlike regexes, near-misses still work. |
| Scope creep into generation | Explicitly out (§12). |
---
## 11. Open decisions — needed before phase 1
1. **Where does the sidecar run?**
- **(a) Dev-only** — runs on operator machines / a dev box. Zero ops. RAG is an enhancement that's simply absent in production.
- **(b) Deployed alongside nginx** — the project gains a backend: hosting, monitoring, a deploy pipeline, an internal network hop. Bigger commitment than the vector DB itself.
*Recommendation: (a) first.* Prove routing accuracy with real operator language before taking on ops.
2. **Does `/ask` (docs) ship to operators, or is it internal?** The indexed docs contain engineering notes, including known backend bugs.
3. **Who writes the held-out eval set?** It has to be someone who hasn't read the matchers, or the test is worthless.
---
## 12. Explicitly out of scope
- **Any generation step.** `/ask` returns source passages with attribution, never paraphrase. Generation needs a hosted model and a key — §2's blocked decision.
- **Embedding operational data.** No bookings, riders or customers in the vector store. Numbers come from the API, always.
- **Replacing the regex matcher.** It becomes the fallback, permanently.
- **Semantic slot extraction.** Dates, statuses and entities stay deterministic.

View File

@@ -0,0 +1,192 @@
import { createAdminCustomer } from 'pages/api/doormileApi';
// ==============================|| Doormile AI — write actions ||============================== //
//
// The FIRST write capability in the assistant. Read the rules before adding
// another one.
//
// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
// PROPOSAL — the exact payload it would submit — and nothing reaches the API
// until the operator presses Create in the panel. Parsing, validation and
// execution are separated here so the proposal can be built, shown and
// discarded without any possibility of a request going out.
//
// Customer creation was chosen as the first write deliberately: it needs two
// required fields where an order needs fourteen, it has no CityGate pincode
// gate, no geocoding and no delivery slot, and a wrong record is an edit
// rather than a rider dispatched to the wrong address.
// "create a customer", "add new client" — an explicit verb + noun. Deliberately
// narrow: nothing here should fire on a question that merely mentions customers.
export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
// Words that are part of the instruction rather than the person's name.
const FILLER =
/\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
// Pulls what it can out of free text. Anything it can't find stays undefined
// and is asked for — never guessed.
export const parseCustomerDraft = (text) => {
const raw = String(text || '');
const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
// Exactly ten digits, standalone. A longer run is not a phone number and
// must not be silently truncated into one.
const phone = (raw.match(/(?<!\d)(\d{10})(?!\d)/) || [])[1];
// Whatever is left after removing the instruction, the phone and the email
// is the person's name.
let nameArea = raw;
if (email) nameArea = nameArea.replace(email, ' ');
if (phone) nameArea = nameArea.replace(phone, ' ');
const nameWords = nameArea
.replace(FILLER, ' ')
.replace(/[^A-Za-z .'-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 1);
return {
firstname: nameWords[0],
lastname: nameWords.slice(1).join(' ') || undefined,
phone,
email
};
};
// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
// digits. If the form would refuse it, the assistant refuses it too, rather
// than letting the server decide.
//
// No tenant check: a customer record carries no tenantid, and the documented
// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
// exists because its dropdown sends a speculative tenantid; the assistant
// doesn't send one at all.
export const validateCustomerDraft = (draft) => {
const missing = [];
if (!draft.firstname) missing.push('the customer’s name');
if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
return { ok: missing.length === 0, missing };
};
// The exact body that will be POSTed.
//
// Documented body for POST /admin/tenantcustomers is
// { firstname, lastname, phone, email } — and a customer record carries NO
// tenantid, which is why the tenant field was removed. A real
// GET /admin/customers response does carry address, doorno, landmark, suburb,
// city, state, postcode, latitude and longitude, so those are sent on a
// best-effort basis: empty strings are dropped rather than sent as noise, and
// if the server ignores the rest nothing breaks.
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
export const buildCustomerPayload = (draft) =>
clean({
firstname: draft.firstname,
lastname: draft.lastname || '',
phone: draft.phone,
email: draft.email || '',
address: draft.address,
doorno: draft.doorno,
landmark: draft.landmark,
suburb: draft.suburb,
city: draft.city,
state: draft.state,
postcode: draft.postcode,
latitude: draft.latitude,
longitude: draft.longitude
});
// The ONLY function in the assistant that mutates anything. Called exclusively
// from the panel's confirm handler — never from an intent's run().
//
// ---- Which endpoint, and why -----------------------------------------------
//
// Writes to POST /admin/customers — the same resource the Customers page
// reads, so a created customer shows up there immediately.
//
// 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.
//
// 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.
//
// 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.
export const executeCreateCustomer = async (payload) => {
const started = Date.now();
const call = {
name: 'createAdminCustomer',
target: 'POST /admin/customers',
stats: Object.keys(payload).join(', ')
};
try {
const res = await createAdminCustomer(payload);
const duration = `${Date.now() - started}ms`;
// doormileApi mutations return the full envelope, so a `success: false`
// arrives as a resolved promise, not a rejection.
if (res && res.success === false) {
return {
ok: false,
message: res.message || 'The server rejected the customer.',
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
};
}
const created = res?.data || res;
return {
ok: true,
id: created?.appcustomerid ?? created?.customerid ?? created?.id,
created,
message: 'Customer created.',
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
};
} catch (err) {
// An HTTP failure used to throw straight past this function, and the panel
// printed a generic "could not be created" with the status thrown away —
// which is the one detail needed to tell "the route does not exist" apart
// 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;
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.';
} else if (status) {
message = `POST /admin/customers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
} else {
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
}
return {
ok: false,
status,
message,
sourceCalls: [
{
...call,
duration,
status: 'error',
errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
}
]
};
}
};

View File

@@ -15,12 +15,22 @@ import {
getCompetitorBranches,
getCarrierPricing,
getMilerSummary,
getBookingTrack
getBookingTrack,
getMilerActivity,
trackConsignment,
getConsignmentLogs,
getAdminTenant,
getTenantLocations
} from 'pages/api/doormileApi';
import { getalltenants, getallridersummary } from 'pages/api/api';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
import { STATUS } from 'themes/dt/tokens';
// Only the read-only half of actions.js belongs here. Tenant resolution,
// payload building and execution live in the panel's submit handler — an
// intent must have no route to a write.
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
import { CREATE_ORDER_TRIGGER } from './orderActions';
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
// ==============================|| Doormile Bot — intent catalog ||============================== //
@@ -275,7 +285,10 @@ const batchFromWords = (text) => {
// pending branch before the assigned branch sees the word "assigned".
const statusFromWords = (text) => {
if (/\bpending\b|\bnot\s*assigned\b|\bunassigned\b|\bawaiting\b/i.test(text)) return 'pending';
if (/\bcancel(led)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled';
// `cancel(led)?` did not match \"cancellation\" — the \b after \"cancel\" fails
// against the following 'l', so \"cancellation rate\" fell through to a plain
// week count.
if (/\bcancel(?:s|led|lation|lations)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled';
if (/\bassigned\b|\baccept(ed)?\b/i.test(text)) return 'assigned';
if (/\bactive\b|\bin[- ]?transit\b|\bin\s*progress\b|\bon\s*the\s*way\b|\bout\s*for\s*delivery\b/i.test(text)) return 'active';
if (/\bundeliver/i.test(text)) return null;
@@ -613,6 +626,32 @@ const bestNameMatch = (needle, records, nameOf) => {
return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0];
};
// ---- L4 analytics helpers --------------------------------------------------
// Percentage with one decimal, but only when the denominator is big enough to
// mean anything. "100% cancelled" off two orders is noise, not a rate.
const rateOf = (part, whole) => (whole > 0 ? `${((part / whole) * 100).toFixed(whole < 20 ? 0 : 1)}%` : '—');
// Buckets rows by day (for a range) or by hour (for a single day), so a trend
// is answered from the same scan every other count uses.
const trendBuckets = (rows, byHour) => {
const counts = new Map();
rows.forEach((b) => {
const d = parseDoormileTimestamp(b.createdat);
if (!d.isValid()) return;
const key = byHour ? d.format('HH:00') : d.format('DD MMM');
counts.set(key, (counts.get(key) || 0) + 1);
});
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
};
// A text bar keeps a trend readable in a 428px panel without a chart library.
const sparkBar = (n, max) => '█'.repeat(Math.max(1, Math.round((n / Math.max(max, 1)) * 12)));
const TREND_TRIGGER =
/\btrend\b|\bper\s+day\b|\bper\s+hour\b|\bby\s+day\b|\bby\s+hour\b|\bdaily\b|\bhourly\b|\bover\s+time\b|\bbreakdown\s+by\s+(?:day|hour)\b/i;
const RATE_TRIGGER = /\brate\b|\bpercentage\b|\bpercent\b|\b%\b|\bratio\b/i;
const mentionsDelay = (text) =>
/\bdelay(ed|s|ing)?\b|\blate\b|\boverdue\b|\bbehind\s+schedule\b|\brunning\s+late\b|\bsla\b|\bbreach(ed|es)?\b|\bat\s*risk\b/i.test(text);
@@ -692,6 +731,58 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// 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.
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.',
form: { kind: 'createOrder', status: 'open' },
sourceCalls: []
})
},
{
// ---- The only write-capable intent -------------------------------------
//
// Ordered FIRST: "create a customer …" contains the word "customer"
// (customerCount matches on it) and usually a 10-digit phone number
// (which orderIdFromWords would read as a weak order id). Neither may get
// the question.
//
// This intent NEVER mutates. It returns a proposal — the exact payload it
// would submit — and the panel does not send anything until the operator
// presses Create. See actions.js.
id: 'createCustomer',
label: 'Create a customer — e.g. "create a customer"',
match: (text) => (CREATE_CUSTOMER_TRIGGER.test(text) ? { text } : null),
run: async ({ text }) => {
// Opens a FORM rather than collecting fields conversationally.
//
// The conversational version shipped and immediately failed in use: a
// reply of just "8494948494" matches no intent, so answerQuestion routed
// it to the "I can't answer that yet" fallback and the number was lost.
// Threading a partial draft through `context` did not help, because the
// router selects an intent by matching TEXT and a bare phone number
// matches nothing. Real inputs remove the parsing step altogether.
//
// Still no mutation here: this returns the form's initial values, and
// the panel submits only when the operator presses Create.
const parsed = parseCustomerDraft(text);
return {
headline: 'Fill this in and I’ll create the customer.',
form: {
kind: 'createCustomer',
status: 'open',
initial: parsed
},
sourceCalls: []
};
}
},
{
id: 'orderLookup',
label: 'Status of a specific order — e.g. "status of order #1234"',
@@ -834,6 +925,241 @@ const INTENTS = [
};
}
},
{
// ---- L3: what has this rider actually done ----------------------------
//
// orderQuery can already count a named rider's ORDERS, but the rider's own
// record carries what the bookings feed cannot: accepted vs rejected,
// distance covered, and current duty state. GET /admin/milers/summary is
// the confirmed-live shape for that (api.js documents the fields).
id: 'riderActivity',
label: 'A rider’s own numbers — e.g. "how is rider Kumar doing today"',
match: (text) => {
if (!mentionsRiders(text)) return null;
if (
!/\bperformance\b|\bactivity\b|\bdoing\b|\bstats\b|\bhow\s+is\b|\bhow\s+has\b|\bcompleted\b|\brejected\b|\baccepted\b|\bkms?\b|\bdistance\b/i.test(
text
)
)
return null;
const name = riderNameFromWords(text);
if (!name) return null;
const range = rangeFromWords(text);
if (range) return { name, start: range.start, end: range.end, label: range.label };
const day = dayFromWords(text);
return { name, start: day, end: day, label: describeDay(day) };
},
run: async ({ name, start, end, label }) => {
const summary = (await getMilerSummary(undefined, start, end)) || [];
const list = Array.isArray(summary) ? summary : [summary];
const rider = bestNameMatch(name, list, (m) => m.displayname || m.name);
if (!rider) return null;
// The activity feed is optional colour — a failure degrades that one
// line rather than the whole answer.
const activity = rider.milerprofileid ? await getMilerActivity(rider.milerprofileid, start, end).catch(() => null) : null;
const assigned = Number(rider.assigned) || 0;
const stats = [
{ label: 'Assigned', value: assigned, color: STATUS.accepted },
{ label: 'Completed', value: Number(rider.completed) || Number(rider.delivered) || 0, color: STATUS.delivered },
{ label: 'Rejected', value: Number(rider.rejected) || 0, color: STATUS.cancelled },
{ label: 'Cancelled', value: Number(rider.cancelled) || 0, color: STATUS.muted }
];
return {
headline: `${rider.displayname || rider.name} — ${rider.availabilitystatus || 'status unknown'} ${label}.`,
metric: { value: Number(rider.completed) || Number(rider.delivered) || 0, label: `Completed ${label}` },
stats,
detail: [
assigned ? `Acceptance ${rateOf(assigned - (Number(rider.rejected) || 0), assigned)} of ${assigned} assigned.` : null,
rider.riderkms ? `${rider.riderkms} km covered.` : null,
rider.hubname ? `Hub: ${rider.hubname}` : null,
rider.onduty != null ? `On duty: ${rider.onduty ? 'yes' : 'no'}` : null,
Array.isArray(activity) && activity.length ? `${activity.length} activity events in range.` : null
]
.filter(Boolean)
.join('\n'),
sourceCalls: [
{ name: 'getMilerSummary', target: '/admin/milers/summary', status: 'complete', stats: `matched ${rider.displayname || name}` },
activity
? {
name: 'getMilerActivity',
target: `/admin/milers/${rider.milerprofileid}/activity`,
status: 'complete',
stats: `${activity.length} events`
}
: null
].filter(Boolean)
};
}
},
{
// ---- L3: follow a parcel ----------------------------------------------
id: 'parcelTrack',
label: 'Track a parcel — e.g. "track consignment DM-CN-123"',
match: (text) => {
if (!/\btrack\b|\bconsignment\b|\bparcel\b|\bshipment\b/i.test(text)) return null;
const code = text.match(/\b([A-Z]{2,}-[A-Za-z0-9-]+)\b/) || text.match(/#\s*([A-Za-z0-9-]{3,})/);
if (!code) return null;
return { trackingno: code[1] };
},
run: async ({ trackingno }) => {
const found = await trackConsignment(trackingno).catch(() => null);
if (!found) {
return {
headline: `I couldn’t find a consignment for ${trackingno}.`,
detail: 'Check the tracking number — it looks like a code but nothing matched.',
sourceCalls: [
{ name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'error', errorMessage: 'No match' }
]
};
}
const id = found.consignmentid ?? found.id;
const logs = id ? await getConsignmentLogs(id).catch(() => null) : null;
const events = Array.isArray(logs) ? logs : [];
return {
headline: `${trackingno} — ${found.status || 'status unknown'}.`,
detail: [found.origin ? `From: ${found.origin}` : null, found.destination ? `To: ${found.destination}` : null]
.filter(Boolean)
.join('\n'),
list: events.length
? {
title: 'Scan history',
items: events.map((e) => ({
label: e.status || e.event || 'event',
meta: e.createdat ? parseDoormileTimestamp(e.createdat).format('DD MMM, hh:mm A') : undefined
}))
}
: undefined,
sourceCalls: [
{ name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'complete' },
logs
? { name: 'getConsignmentLogs', target: `/admin/consignments/${id}/logs`, status: 'complete', stats: `${events.length} events` }
: null
].filter(Boolean)
};
}
},
{
// ---- L3: one tenant in depth -------------------------------------------
id: 'tenantDetail',
label: 'A tenant’s detail — e.g. "tell me about Acme Foods"',
match: (text) => {
if (!/\btenant\b|\babout\b|\bdetails?\s+(?:of|for)\b/i.test(text)) return null;
if (/\bhow many\b/i.test(text)) return null; // that's tenantList
const name = tenantNameCandidate(text) || riderNameFromWords(text);
return name ? { name } : null;
},
run: async ({ name }) => {
const tenants = (await getalltenants()) || [];
const match = bestNameMatch(name, tenants, (t) => t.tenantname);
if (!match) return null;
const [detail, locations] = await Promise.all([
getAdminTenant(match.tenantid).catch(() => null),
getTenantLocations(match.tenantid).catch(() => null)
]);
const t = detail || match;
const sites = Array.isArray(locations) ? locations : [];
return {
headline: `${t.tenantname}${t.status ? ` — ${t.status}` : ''}.`,
metric: { value: sites.length, label: 'Pickup locations' },
detail: [t.primaryemail ? `Email: ${t.primaryemail}` : null, t.contactno ? `Phone: ${t.contactno}` : null]
.filter(Boolean)
.join('\n'),
list: sites.length
? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) }
: undefined,
sourceCalls: [
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
{ name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' },
{
name: 'getTenantLocations',
target: `/admin/tenants/${match.tenantid}/locations`,
status: locations ? 'complete' : 'error',
stats: `${sites.length} locations`
}
]
};
}
},
{
// ---- L4: trend over time ------------------------------------------------
//
// Ordered above the plain counting intents: "orders per day this week"
// contains "orders" and a range, so weekOrders would otherwise answer it
// with a single number and drop the "per day" entirely.
id: 'orderTrend',
label: 'Order trend — e.g. "orders per day this week"',
match: (text) => {
if (mentionsRiders(text) || mentionsDelay(text)) return null;
if (!TREND_TRIGGER.test(text)) return null;
if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null;
const byHour = /\bper\s+hour\b|\bhourly\b|\bby\s+hour\b/i.test(text);
const range = rangeFromWords(text);
if (range) return { start: range.start, end: range.end, label: range.label, byHour };
const day = dayFromWords(text);
return { start: day, end: day, label: describeDay(day), byHour: true };
},
run: async ({ start, end, label, byHour }) => {
const scan = await fetchBookingsInRange(start, end);
const buckets = trendBuckets(scan.rows, byHour);
if (!buckets.length) {
return { headline: `No orders to chart ${label}.`, sourceCalls: [scanCall(scan, '0 rows')] };
}
const max = Math.max(...buckets.map(([, n]) => n));
const peak = buckets.find(([, n]) => n === max);
return {
headline: `${countPhrase(scan, scan.rows.length)} order${scan.rows.length === 1 ? '' : 's'} ${label}, peaking at ${max} (${
peak[0]
}).`,
metric: { value: max, label: `Peak ${byHour ? 'hour' : 'day'} — ${peak[0]}` },
list: {
title: byHour ? 'Orders per hour' : 'Orders per day',
numbered: false,
items: buckets.map(([k, n]) => ({ label: k, meta: `${sparkBar(n, max)} ${n}` }))
},
detail: truncationNote(scan).trim() || undefined,
sourceCalls: [scanCall(scan, `${scan.rows.length} rows → ${buckets.length} buckets`)]
};
}
},
{
// ---- L4: rates ---------------------------------------------------------
id: 'orderRate',
label: 'A rate — e.g. "cancellation rate this week"',
match: (text) => {
if (mentionsRiders(text) || mentionsDelay(text)) return null;
if (!RATE_TRIGGER.test(text)) return null;
const group = statusFromWords(text);
if (!group) return null;
const range = rangeFromWords(text);
if (range) return { group, start: range.start, end: range.end, label: range.label };
if (mentionsAnyDate(text)) {
const day = dayFromWords(text);
return { group, start: day, end: day, label: describeDay(day) };
}
return { group, start: null, end: null, label: null };
},
run: async ({ group, start, end, label }) => {
const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
const rows = scan.rows;
const matched = rows.filter((b) => isInGroup(b.status, group));
const noun = ORDER_STATUS_LABELS[group].toLowerCase();
const scopeText = label ? ` ${label}` : '';
return {
headline: `${rateOf(matched.length, rows.length)} of orders are ${noun}${scopeText}.`,
metric: { value: rateOf(matched.length, rows.length), label: `${ORDER_STATUS_LABELS[group]} rate${scopeText}` },
stats: statusStats(rows),
detail:
`${matched.length} of ${rows.length} orders.` +
(rows.length < 20 ? ' Small sample — treat the percentage loosely.' : '') +
(label ? '' : '\nCovers every order, not just today.') +
truncationNote(scan),
sourceCalls: [scanCall(scan, `${matched.length}/${rows.length} ${noun}`)]
};
}
},
{
// ---- Composable order questions ----------------------------------------
//
@@ -985,10 +1311,14 @@ const INTENTS = [
}${scopeText}.`,
metric: { value: rows.length, label: [filterText, label].filter(Boolean).join(' · ') || 'Orders' },
stats: statusStats(rows),
list: rows.length
? {
title: 'Matching orders',
items: rows.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
}
: undefined,
detail:
(rows.length
? `Order IDs: ${rows.slice(0, 10).map(bookingLabel).join(', ')}${rows.length > 10 ? ` …and ${rows.length - 10} more` : ''}`
: 'Nothing matched every part of that question.') +
(rows.length ? '' : 'Nothing matched every part of that question.') +
(label ? '' : '\nCovers every order, not just today.') +
truncationNote(scan),
sourceCalls
@@ -1022,13 +1352,15 @@ const INTENTS = [
const tenants = (await getalltenants()) || [];
return {
headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`,
detail: tenants.length
? `${tenants
.slice(0, 10)
.map((t) => t.tenantname)
.filter(Boolean)
.join(', ')}${tenants.length > 10 ? ` …and ${tenants.length - 10} more` : ''}`
: undefined,
metric: { value: tenants.length, label: 'Tenants' },
// Full list, not the first ten. "…and 4 more" left the operator with
// no way to see the rest at all.
list: {
title: 'Tenants',
items: tenants
.filter((t) => t.tenantname)
.map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined }))
},
sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
};
}
@@ -1083,6 +1415,9 @@ const INTENTS = [
return {
headline: `${countPhrase(scan, late.length)} order${late.length === 1 ? '' : 's'} running late${scopeNote}.`,
metric: { value: late.length, label: `Late${label ? `, ${label}` : ' right now'}` },
list: late.length
? { title: 'Late orders', items: late.map((b) => ({ label: bookingLabel(b), meta: formatLateness(b, now) })) }
: undefined,
stats: [
{ label: 'Late', value: late.length, color: STATUS.cancelled },
{ label: 'Due within 1h', value: buckets['at-risk'].length, color: STATUS.pending },
@@ -1091,12 +1426,7 @@ const INTENTS = [
],
detail:
[
late.length
? `Late: ${late
.slice(0, 8)
.map((b) => `${bookingLabel(b)} (${formatLateness(b, now)})`)
.join(', ')}${late.length > 8 ? ` …and ${late.length - 8} more` : ''}`
: 'Nothing is past its promised delivery time.',
late.length ? null : 'Nothing is past its promised delivery time.',
hubs ? `By hub: ${hubs.map(([hub, n]) => `${hub} ${n}`).join(', ')}` : null,
buckets['no-eta'].length
? `${buckets['no-eta'].length} open order${
@@ -1227,12 +1557,8 @@ const INTENTS = [
const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length;
return {
headline: `${hubs.length} hub${hubs.length === 1 ? '' : 's'} total, ${active} active.`,
detail: hubs.length
? hubs
.slice(0, 10)
.map((h) => `${h.hubname || `Hub #${h.hubid}`} — ${h.status || 'unknown'}`)
.join('\n') + (hubs.length > 10 ? `\n…and ${hubs.length - 10} more` : '')
: undefined,
metric: { value: active, label: `Active of ${hubs.length} hubs` },
list: { title: 'Hubs', items: hubs.map((h) => ({ label: h.hubname || `Hub #${h.hubid}`, meta: h.status || 'unknown' })) },
sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `${hubs.length} hubs` }]
};
}
@@ -1459,12 +1785,13 @@ const INTENTS = [
batch
)} ${describeDay(day)}.`,
metric: { value: matched.length, label: `${getBatchLabel(batch)} ${describeDay(day)}` },
detail:
(matched.length
? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${
matched.length > 10 ? ` …and ${matched.length - 10} more` : ''
}`
: 'No orders fall in this batch for that day.') + truncationNote(scan),
list: matched.length
? {
title: `${getBatchLabel(batch)} orders`,
items: matched.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
}
: undefined,
detail: (matched.length ? '' : 'No orders fall in this batch for that day.') + truncationNote(scan),
sourceCalls: [scanCall(scan, `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`)]
};
}

View File

@@ -0,0 +1,145 @@
import { createExpressBooking, getTenantLocations } from 'pages/api/doormileApi';
import { getalltenants } from 'pages/api/api';
// ==============================|| Doormile AI — create order ||============================== //
//
// Second write capability. Same contract as actions.js: nothing here mutates
// except executeCreateOrder, which the panel calls only when the operator
// presses Create.
//
// Order creation is materially riskier than customer creation — a wrong record
// dispatches a real rider.
//
// ⚠ `pickuplocationid` DOES NOT WORK. express-console-api.md documents it as
// the way to reference a stored pickup site, and this file was originally
// built on it, but createorder1.js records (in two places) that the field
// live-500s on POST /admin/expressbooking — a reported backend bug, with no
// frontend workaround other than not using it. The page therefore always
// sends the RAW pickup fields, copying them out of the chosen saved location.
//
// This does the same. The operator still picks a saved location — that part
// is good UX and keeps CityGate satisfied, since a stored site has already
// passed it — but what goes on the wire is pickupaddress / pickuppincode /
// pickupcity / pickuplatitude / pickuplongitude, exactly as the page sends.
export const CREATE_ORDER_TRIGGER = /\b(?:create|add|place|book|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:order|booking|delivery)\b/i;
// Doormile's own service tiers (express-console-api.md).
export const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
// CityGate is enforced server-side, before the handler runs. Checking it here
// too means the operator is told which cities are open BEFORE submitting,
// instead of getting an opaque middleware rejection back.
export const OPEN_CITY_PREFIXES = {
641: 'Coimbatore',
600: 'Chennai',
560: 'Bengaluru',
500: 'Hyderabad',
629: 'Nagercoil'
};
export const cityGateFor = (pincode) => {
const prefix = String(pincode || '').slice(0, 3);
return OPEN_CITY_PREFIXES[prefix] || null;
};
export const loadOrderTenants = async () => (await getalltenants()) || [];
// Saved pickup sites for a tenant. Each carries address / city / pincode /
// coordinates — which is what the payload copies onto the booking, since
// `pickuplocationid` itself 500s (see the note at the top of this file).
export const loadPickupLocations = async (tenantid) => {
if (!tenantid) return [];
return (await getTenantLocations(tenantid)) || [];
};
const PHONE_RE = /^\d{10}$/;
// Mirrors createorder1.js's own gate, plus the two rules the endpoint enforces
// that the page leaves to the server (non-empty parcels, CityGate).
export const validateOrderDraft = (d) => {
const errors = {};
if (!d.tenantid) errors.tenantid = 'Choose a tenant';
// Still required: the operator picks a saved site so its address/pincode/
// coordinates can be copied onto the booking. The id itself is never sent.
if (!d.pickuplocationid) errors.pickuplocationid = 'Choose a pickup location';
if (!d.customer_name?.trim()) errors.customer_name = 'Required';
if (!PHONE_RE.test(String(d.customer_phone || '').trim())) errors.customer_phone = 'Enter exactly 10 digits';
if (!d.deliveryaddress?.trim()) errors.deliveryaddress = 'Required';
if (!String(d.deliverypincode || '').trim()) errors.deliverypincode = 'Required';
if (!d.itemdescription?.trim()) errors.itemdescription = 'Describe what is being sent';
if (d.finalprice === '' || d.finalprice == null || Number.isNaN(Number(d.finalprice))) errors.finalprice = 'Enter an amount';
// Delivery coordinates come from the address search. Without them the
// optimiser has nothing to route against, so refuse rather than send a
// booking that can never be dispatched.
if (!Number.isFinite(Number(d.deliverylatitude)) || !Number.isFinite(Number(d.deliverylongitude))) {
errors.deliveryaddress = 'Pick the address from the suggestions so coordinates are captured';
}
return { ok: Object.keys(errors).length === 0, errors };
};
// The exact body that will be POSTed. Mirrors createorder1.js's own payload —
// raw pickup fields, never `pickuplocationid` (see the note at the top).
//
// `pickup` is the saved location record the operator chose; its address,
// pincode, city and coordinates are copied onto the booking.
export const buildOrderPayload = (d, pickup) => ({
tenantid: Number(d.tenantid),
pickupaddress: pickup?.address || '',
pickuppincode: String(pickup?.pincode || ''),
pickupcity: pickup?.city || '',
pickuplatitude: Number(pickup?.latitude) || 0,
pickuplongitude: Number(pickup?.longitude) || 0,
customer_name: d.customer_name.trim(),
customer_phone: String(d.customer_phone).trim(),
deliveryaddress: d.deliveryaddress.trim(),
deliverypincode: String(d.deliverypincode).trim(),
deliverycity: d.deliverycity || '',
deliverylatitude: Number(d.deliverylatitude),
deliverylongitude: Number(d.deliverylongitude),
service_option: SERVICE_OPTIONS.includes(d.service_option) ? d.service_option : 'Normal',
finalprice: Number(d.finalprice),
notes: d.notes || '',
// A parcel entry has no quantity field — N items means N entries, which is
// how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
parcels: Array.from({ length: Math.max(1, Number(d.quantity) || 1) }, () => ({
itemcategory: d.itemcategory || 'General',
itemdescription: d.itemdescription.trim(),
declaredvalue: Number(d.declaredvalue) || 0
}))
});
// The only order-writing function in the assistant.
export const executeCreateOrder = async (payload) => {
const started = Date.now();
const call = {
name: 'createExpressBooking',
target: 'POST /admin/expressbooking',
stats: `tenant ${payload.tenantid}, ${payload.parcels.length} parcel${payload.parcels.length === 1 ? '' : 's'}`
};
try {
const res = await createExpressBooking(payload);
const duration = `${Date.now() - started}ms`;
if (res && res.success === false) {
return {
ok: false,
message: res.message || 'The server rejected the order.',
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
};
}
const created = res?.data || res;
return {
ok: true,
id: created?.bookingid ?? created?.id,
bookingno: created?.bookingno,
created,
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created ${created?.bookingno || created?.bookingid || '—'}` }]
};
} catch (err) {
const duration = `${Date.now() - started}ms`;
const message = err.response?.data?.message || err.message || 'The order could not be created.';
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
/* ==========================================================================
Create Order — premium logistics layout
--------------------------------------------------------------------------
Restyles the existing Create Order structure into a Stripe/Linear-grade
form: a real page header, a stage indicator, distinct Pickup → Drop journey
cards, and a pricing card that reads as a quote rather than a form field.
Follows the same convention as OrdersRedesign.css / Dispatch.css — a
page-scoped stylesheet imported by its own page — so hover, focus, sticky
and breakpoint behaviour are all expressible, which inline styles can't do.
Every colour is a local `--co-*` token defined once here so the whole page
moves together.
========================================================================== */
.co-root {
--co-brand: #d7193f;
--co-pickup: #10b981;
--co-drop: #f97316;
--co-text: #1f2937;
--co-text-2: #6b7280;
--co-text-3: #9ca3af;
--co-border: #e5e7eb;
--co-surface: #ffffff;
--co-bg: #f8fafc;
--co-radius: 5px;
--co-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
}
/* --------------------------------------------------------------------------
Page header — informative, not tall
-------------------------------------------------------------------------- */
.co-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 0 0 16px;
border-bottom: 1px solid var(--co-border);
margin-bottom: 20px;
}
.co-root .co-title {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--co-text);
}
.co-root .co-subtitle {
font-size: 13px;
line-height: 1.5;
color: var(--co-text-2);
max-width: 60ch;
}
/* --------------------------------------------------------------------------
Stage indicator
--------------------------------------------------------------------------
The layout rules below are NOT scoped under .co-root. Only theming needs
the scope; structure must survive it. When `co-root` was being dropped by
PageShell (it didn't forward className), every scoped rule failed silently
and the chips rendered as run-together plain text — "1Pickup & Drop2Schedule".
Keeping layout unscoped means the worst case is unthemed, not unreadable.
-------------------------------------------------------------------------- */
.co-steps {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
}
.co-step {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 11px;
border: 1px solid var(--co-border);
border-radius: 999px;
background: var(--co-surface);
font-size: 12.5px;
font-weight: 500;
color: var(--co-text-3);
white-space: nowrap;
}
.co-root .co-step[data-state='active'] {
color: var(--co-brand);
border-color: color-mix(in srgb, var(--co-brand) 35%, transparent);
background: color-mix(in srgb, var(--co-brand) 6%, #fff);
}
.co-root .co-step[data-state='done'] {
color: var(--co-pickup);
border-color: color-mix(in srgb, var(--co-pickup) 30%, transparent);
background: color-mix(in srgb, var(--co-pickup) 6%, #fff);
}
.co-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
background: currentColor;
color: #fff;
}
.co-root .co-step[data-state='done'] .co-step-num::after {
content: '✓';
}
.co-step-sep {
flex: 0 0 auto;
width: 14px;
height: 1px;
background: var(--co-border);
}
/* --------------------------------------------------------------------------
Journey — Pickup ●───→● Drop
-------------------------------------------------------------------------- */
.co-journey {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 13px;
margin-bottom: 14px;
border: 1px solid var(--co-border);
border-radius: var(--co-radius);
background: var(--co-surface);
font-size: 12.5px;
color: var(--co-text-2);
overflow: hidden;
}
.co-journey-dot {
flex: 0 0 auto;
width: 9px;
height: 9px;
border-radius: 999px;
}
.co-root .co-journey-dot[data-end='pickup'] {
background: var(--co-pickup);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-pickup) 18%, transparent);
}
.co-root .co-journey-dot[data-end='drop'] {
background: var(--co-drop);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-drop) 18%, transparent);
}
.co-journey-line {
flex: 1 1 auto;
min-width: 24px;
height: 1px;
background: linear-gradient(90deg, var(--co-pickup), var(--co-drop));
opacity: 0.5;
}
.co-journey-label {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--co-text);
}
/* --------------------------------------------------------------------------
Cards — one subtle border, one very light shadow. No heavy chrome.
-------------------------------------------------------------------------- */
.co-root .co-card {
border: 1px solid var(--co-border) !important;
border-radius: var(--co-radius) !important;
background: var(--co-surface);
box-shadow: var(--co-shadow) !important;
overflow: hidden;
}
/* The end-accent strip is what makes Pickup and Drop distinguishable at a
glance without colouring the whole card. */
.co-root .co-card[data-end='pickup'] {
border-top: 2px solid var(--co-pickup) !important;
}
.co-root .co-card[data-end='drop'] {
border-top: 2px solid var(--co-drop) !important;
}
/* --------------------------------------------------------------------------
Pricing — reads as a quote, not a form field
-------------------------------------------------------------------------- */
.co-price-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
font-size: 13px;
color: var(--co-text-2);
border-bottom: 1px dashed var(--co-border);
}
.co-root .co-price-row:last-of-type {
border-bottom: none;
}
.co-root .co-price-row b {
font-weight: 500;
color: var(--co-text);
font-variant-numeric: tabular-nums;
}
.co-price-total {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding-top: 12px;
border-top: 1px solid var(--co-border);
}
.co-root .co-price-total-label {
font-size: 13px;
font-weight: 500;
color: var(--co-text);
}
.co-root .co-price-total-value {
font-size: 24px;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--co-pickup);
font-variant-numeric: tabular-nums;
}
.co-root .co-price-note {
font-size: 11.5px;
color: var(--co-text-3);
}
/* --------------------------------------------------------------------------
Sticky order summary (desktop only)
-------------------------------------------------------------------------- */
.co-root .co-summary {
position: sticky;
top: calc(var(--appshell-header-height, 64px) + 12px);
}
.co-root .co-summary-leg {
display: flex;
gap: 9px;
align-items: flex-start;
}
.co-root .co-summary-leg-text {
min-width: 0;
font-size: 12.5px;
line-height: 1.45;
color: var(--co-text);
overflow-wrap: anywhere;
}
.co-root .co-summary-muted {
font-size: 11.5px;
color: var(--co-text-3);
}
/* --------------------------------------------------------------------------
Field polish — labels above, calm borders, a clear focus ring
-------------------------------------------------------------------------- */
.co-root input:focus-visible,
.co-root textarea:focus-visible {
outline: none;
border-color: var(--co-brand) !important;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-brand) 12%, transparent) !important;
}
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@media (max-width: 1023px) {
.co-root .co-summary {
position: static;
}
}
@media (max-width: 640px) {
.co-root .co-title {
font-size: 20px;
}
.co-root .co-header {
gap: 10px;
}
.co-root .co-steps {
gap: 4px;
}
.co-root .co-step {
padding: 5px 9px;
font-size: 11.5px;
}
/* The step separators are decoration; drop them before the chips wrap
awkwardly on a 360px screen. */
.co-root .co-step-sep {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.co-root * {
transition: none !important;
animation: none !important;
}
}

View File

@@ -6,7 +6,7 @@
.total-charge-badge {
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(101, 56, 122, 0.10) 100%);
border: 1px solid rgba(101, 56, 122, 0.18);
border-radius: 10px;
border-radius: 5px;
padding: 10px 14px;
display: flex;
align-items: center;

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { calculateDrivingDistance, calculateTotalCharge } from '../../../utils/distance';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '../../../utils/distance';
import './CreateOrder.css';
import { FaPhoneAlt, FaUser } from 'react-icons/fa';
import { GiDoorHandle } from 'react-icons/gi';
import { FaLandmarkDome, FaLocationDot } from 'react-icons/fa6';
@@ -140,6 +141,8 @@ const Createorder1 = () => {
const [pricePerKm, setPricePerKm] = useState(0);
const [minKm, setMinKm] = useState(0);
const [totalCharge, setTotalCharge] = useState(0);
// Real routed minutes from OSRM; null when the Haversine fallback ran.
const [durationMin, setDurationMin] = useState(null);
const [category, setCategory] = useState(null);
const [weight, setWeight] = useState('');
// Per express-console-api.md's "Tenant scoping" section: token tenantid
@@ -206,6 +209,10 @@ const Createorder1 = () => {
try {
const roundedDistance = await calculateDrivingDistance(pickup, drop);
setDistance(roundedDistance);
// Real routed minutes from OSRM. Null when the Haversine fallback ran —
// a straight-line estimate has no honest duration, so nothing is shown
// rather than a number derived from a guessed speed.
setDurationMin(getLastRouteDurationMin());
const total = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm);
setTotalCharge(total);
@@ -606,31 +613,16 @@ const Createorder1 = () => {
<>
{loading && <Loader />}
<PageShell>
<Card padding={3} elevation="none" style={{ position: 'sticky', top: 'var(--appshell-header-height, 64px)', zIndex: 100 }}>
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={44}>
<MdAddShoppingCart size={22} />
<PageShell className="co-root">
<VStack className="co-header" gap={0} padding={0}>
<HStack justify="between" vAlign="start" gap={2} wrap="wrap" padding={0}>
<HStack gap={2} vAlign="center" padding={0}>
<AccentAvatar color={BRAND} selected size={40}>
<MdAddShoppingCart size={20} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="heading" weight="bold">
Create Order
</Text>
<HStack gap={1} vAlign="center">
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: '#10b981',
boxShadow: '0 0 0 4px rgba(16,185,129,0.18)'
}}
/>
<Text type="supporting" color="secondary">
Fill pickup, drop, schedule &amp; pricing details
</Text>
</HStack>
<VStack gap={0.5} padding={0}>
<Text className="co-title">Create Order</Text>
<Text className="co-subtitle">Create a new delivery order by entering pickup, drop, schedule and pricing details.</Text>
</VStack>
</HStack>
@@ -710,11 +702,50 @@ const Createorder1 = () => {
</div>
)}
</HStack>
</Card>
</VStack>
{/* Stage indicator. Derived from the form's own state rather than a
wizard step counter — the page is one scrollable form, so this
reports progress instead of gating it. */}
<HStack className="co-steps" padding={0} gap={0}>
{[
{ n: 1, label: 'Pickup & Drop', done: Boolean(pickCust.address && dropCust.address) },
{ n: 2, label: 'Schedule', done: Boolean(selectedtime) },
{ n: 3, label: 'Package', done: Boolean(category) },
{ n: 4, label: 'Review', done: showDistance && totalCharge > 0 }
].map((step, i, all) => {
const active = !step.done && all.slice(0, i).every((prev) => prev.done);
return (
<HStack key={step.n} padding={0} gap={0} vAlign="center">
{i > 0 && <span className="co-step-sep" />}
<span className="co-step" data-state={step.done ? 'done' : active ? 'active' : 'todo'}>
<span className="co-step-num">{step.done ? '' : step.n}</span>
{step.label}
</span>
</HStack>
);
})}
</HStack>
{/* The delivery journey, stated once at the top so the order's shape
is obvious before reading any field. */}
<HStack className="co-journey" padding={0} gap={0} vAlign="center">
<span className="co-journey-dot" data-end="pickup" />
<span className="co-journey-label">{pickCust.address || 'Pickup address'}</span>
<span className="co-journey-line" />
{showDistance && (
<span className="co-price-note">
{distance} km{durationMin ? ` · ${durationMin} min` : ''}
</span>
)}
<span className="co-journey-line" />
<span className="co-journey-label">{dropCust.address || 'Drop address'}</span>
<span className="co-journey-dot" data-end="drop" />
</HStack>
<Grid columns={{ minWidth: 420, repeat: 'fit' }} gap={3}>
{/* ================================================= || Pickup || ================================================= */}
<Card padding={0} elevation="none">
<Card padding={0} elevation="none" className="co-card" data-end="pickup">
<SectionHeader
color="#0ea5e9"
icon={<MdLocationOn size={16} />}
@@ -841,7 +872,7 @@ const Createorder1 = () => {
</Card>
{/* ================================================= || Drop || ================================================= */}
<Card padding={0} elevation="none">
<Card padding={0} elevation="none" className="co-card" data-end="drop">
<SectionHeader
color={BRAND}
icon={<MdLocalShipping size={16} />}
@@ -968,7 +999,7 @@ const Createorder1 = () => {
</Card>
{/* ================================================= || Time || ================================================= */}
<Card padding={0} elevation="none">
<Card padding={0} elevation="none" className="co-card">
<SectionHeader color="#f59e0b" icon={<MdSchedule size={16} />} title="Schedule" subtitle="Pickup date & time slot" />
<VStack gap={2} padding={3}>
<DateInput
@@ -995,7 +1026,7 @@ const Createorder1 = () => {
<div
style={{
padding: 10,
borderRadius: 8,
borderRadius: 5,
border: `1px solid ${DT.divider}`,
background: DT.surfaceAlt,
maxHeight: 150,
@@ -1045,7 +1076,7 @@ const Createorder1 = () => {
</Card>
{/* ================================================= || Distance & Pricing || ================================================= */}
<Card padding={0} elevation="none">
<Card padding={0} elevation="none" className="co-card">
<SectionHeader
color="#10b981"
icon={<MdAttachMoney size={16} />}
@@ -1053,11 +1084,37 @@ const Createorder1 = () => {
subtitle="Auto-calculated from pickup → drop"
/>
<VStack gap={3} padding={3}>
{showDistance && (
<HStack justify="between">
<Badge variant="blue" label={`Distance: ${distance} km`} />
<Badge variant="blue" label={`Charge: ₹${totalCharge.toFixed(2)}`} />
</HStack>
{showDistance ? (
<VStack gap={0} padding={0}>
<HStack className="co-price-row" padding={0} gap={0}>
<span>Distance</span>
<b>{distance} km</b>
</HStack>
{durationMin ? (
<HStack className="co-price-row" padding={0} gap={0}>
<span>Estimated duration</span>
<b>{durationMin} min</b>
</HStack>
) : null}
<HStack className="co-price-row" padding={0} gap={0}>
<span>Base fare</span>
<b>₹{Number(basePrice || 0).toFixed(2)}</b>
</HStack>
<HStack className="co-price-row" padding={0} gap={0}>
<span>
Distance charge
{minKm ? ` · beyond ${minKm} km` : ''}
</span>
<b>₹{Math.max(0, Number(totalCharge || 0) - Number(basePrice || 0)).toFixed(2)}</b>
</HStack>
<HStack className="co-price-total" padding={0} gap={0}>
<span className="co-price-total-label">Total amount</span>
<span className="co-price-total-value">₹{Number(totalCharge || 0).toFixed(2)}</span>
</HStack>
<Text className="co-price-note">Auto-calculated from pickup → drop using this tenant’s pricing.</Text>
</VStack>
) : (
<Text className="co-price-note">Enter both addresses and the distance and price are calculated automatically.</Text>
)}
<VStack gap={1} padding={0}>
@@ -1089,7 +1146,7 @@ const Createorder1 = () => {
<VStack gap={1} padding={0}>
<Text weight="bold">Weight</Text>
<HStack justify="between" gap={1} wrap="wrap" padding={2} style={{ border: '1px solid #eee', borderRadius: 8 }}>
<HStack justify="between" gap={1} wrap="wrap" padding={2} style={{ border: '1px solid #eee', borderRadius: 5 }}>
{WEIGHT_OPTIONS.map((w) => (
<span
key={w}
@@ -1116,7 +1173,7 @@ const Createorder1 = () => {
</Grid>
{/* ============================== || Cash Collect & Quantity || ============================== */}
<Card padding={0} elevation="none">
<Card padding={0} elevation="none" className="co-card">
<SectionHeader
color="#6366f1"
icon={<MdInventory2 size={16} />}
@@ -1132,7 +1189,7 @@ const Createorder1 = () => {
</Card>
{/* ================================================= || Notes || ================================================= */}
<Card padding={0} elevation="none">
<Card padding={0} elevation="none" className="co-card">
<SectionHeader
color={DT.textSecondary}
icon={<MdNotes size={16} />}

View File

@@ -90,7 +90,7 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
style={{
background: '#fff',
border: '1px solid #eef2f6',
borderRadius: 10,
borderRadius: 5,
padding: '6px 8px',
display: 'flex',
alignItems: 'center',
@@ -103,7 +103,7 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
style={{
width: 26,
height: 26,
borderRadius: 8,
borderRadius: 5,
background: `${accent}18`,
color: accent,
display: 'flex',
@@ -239,7 +239,7 @@ const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
textAlign: 'left',
gap: 10,
padding: '10px 12px',
borderRadius: 10,
borderRadius: 5,
border: '1.5px solid #eef2f6',
background: '#fff',
opacity: disabled ? 0.6 : 1,
@@ -250,7 +250,7 @@ const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
style={{
width: 32,
height: 32,
borderRadius: 8,
borderRadius: 5,
background: `${accent}15`,
color: accent,
display: 'flex',
@@ -1164,13 +1164,13 @@ const MultipleOrders = () => {
gap={2}
vAlign="center"
padding={2}
style={{ border: '1px solid #eef2f6', borderInlineStart: '3px solid #0ea5e9', borderRadius: 10, background: '#fbfcff' }}
style={{ border: '1px solid #eef2f6', borderInlineStart: '3px solid #0ea5e9', borderRadius: 5, background: '#fbfcff' }}
>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
borderRadius: 5,
background: 'linear-gradient(135deg,#0ea5e9,#0284c7)',
color: '#fff',
display: 'flex',
@@ -1312,7 +1312,7 @@ const MultipleOrders = () => {
marginBottom: 8,
background: 'rgba(245,158,11,0.06)',
border: '1.5px solid rgba(245,158,11,0.25)',
borderRadius: 10
borderRadius: 5
}}
>
<VStack gap={0} padding={0}>
@@ -1591,7 +1591,7 @@ const MultipleOrders = () => {
minHeight: 240,
background: 'rgba(24,144,255,0.03)',
border: '1px dashed rgba(24,144,255,0.25)',
borderRadius: 12,
borderRadius: 5,
textAlign: 'center'
}}
>

View File

@@ -1050,7 +1050,13 @@ const Orders = () => {
// readers announce the column's sort state from
// here, not from the clickable content inside.
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}
@@ -1173,7 +1179,10 @@ const Orders = () => {
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ textAlign: cell.column.columnDef.meta?.align, maxWidth: cell.column.columnDef.maxSize || cell.column.getSize() }}
style={{
textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>

View File

@@ -1366,7 +1366,7 @@ export default function OrdersDetails() {
justify="between"
vAlign="center"
padding={2}
style={{ borderRadius: 8, backgroundColor: tint(item.color), border: `1px solid ${edge(item.color)}` }}
style={{ borderRadius: 5, backgroundColor: tint(item.color), border: `1px solid ${edge(item.color)}` }}
>
<Text weight="semibold">{item.label}</Text>
<Badge variant={item.value ? 'neutral' : 'neutral'} label={item.value || 'None'} />

View File

@@ -157,7 +157,7 @@ const LogChip = ({ color, icon: Icon, label, value }) => (
gap={1.5}
vAlign="center"
padding={2}
style={{ borderRadius: 12, background: '#fff', border: `1px solid ${edge(color)}`, minWidth: 0 }}
style={{ borderRadius: 5, background: '#fff', border: `1px solid ${edge(color)}`, minWidth: 0 }}
>
<AccentAvatar color={color} size={28}>
<Icon size={14} />

View File

@@ -53,6 +53,19 @@ export const doormileTheme = defineTheme({
code: { family: 'ui-monospace', fallbacks: 'SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' }
},
tokens: {
// ---- Corner radius -------------------------------------------------
// Overridden at the token level so EVERY Astryx component moves at once
// (cards, dialogs, popovers, buttons, inputs, menus, chat surfaces).
// Setting it per-component would leave the ones nobody remembered to
// list still rounded.
//
// `--radius-full` is deliberately untouched: it drives avatars, status
// dots and pill chips, which are meant to be round.
'--radius-page': '5px',
'--radius-container': '5px',
'--radius-element': '5px',
'--radius-inner': '5px',
'--radius-chat': '5px',
'--color-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
'--color-icon-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
'--color-text-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],

View File

@@ -7,10 +7,21 @@
// support.
// ============================================================================
export const DT = {
// Corner radius is uniform across the console. Boxes were 10-14px, which
// reads soft and consumer-ish on dense operator screens; 5px is tight
// enough for table density while still reading as a rounded surface.
//
// These are the shared values — change them here, not per page. The Astryx
// components read the matching `--radius-*` tokens set in themes/astryx.js,
// so both have to move together.
//
// radiusPill stays fully round on purpose — it is used for things that are
// shaped like pills or circles (status chips, avatars, dots). Squaring those
// doesn't make them consistent, it makes them look broken.
radiusPill: 999,
radiusCard: 14,
radiusInner: 10,
radiusField: 10,
radiusCard: 5,
radiusInner: 5,
radiusField: 5,
// Restrained, low-contrast elevation — corporate (Linear/Stripe), not flashy.
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',

View File

@@ -6,6 +6,13 @@
* @param {Object} destination - { latitude, longitude }
* @returns {Promise<number>} - Round distance in KM
*/
// Duration from the most recent OSRM route, in minutes, or null when the
// Haversine fallback was used (a straight-line estimate has no honest
// duration — better to show nothing than a made-up ETA).
let lastRouteDurationMin = null;
export const getLastRouteDurationMin = () => lastRouteDurationMin;
export const calculateDrivingDistance = async (origin, destination) => {
const lat1 = origin?.latitude;
const lon1 = origin?.longitude;
@@ -26,6 +33,10 @@ export const calculateDrivingDistance = async (origin, destination) => {
if (data.routes && data.routes.length > 0) {
const distanceInMeters = data.routes[0].distance;
const distanceInKm = distanceInMeters / 1000;
// OSRM already returns the driving duration alongside the distance;
// it was being discarded. Cached here so callers that want an ETA get
// a REAL routed figure instead of dividing km by a guessed speed.
lastRouteDurationMin = Number.isFinite(data.routes[0].duration) ? Math.round(data.routes[0].duration / 60) : null;
return Math.round(distanceInKm);
}
}
@@ -44,7 +55,9 @@ export const calculateDrivingDistance = async (origin, destination) => {
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const aerialDistance = R * c;
// No route was fetched, so there is no honest duration to report.
lastRouteDurationMin = null;
return Math.round(aerialDistance * 1.3);
};