updates on the ui changes and the ai bot implementation
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
193
src/pages/nearle/assistant/DoormileAI/AICustomerForm.js
Normal file
193
src/pages/nearle/assistant/DoormileAI/AICustomerForm.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
276
src/pages/nearle/assistant/DoormileAI/AIOrderForm.js
Normal file
276
src/pages/nearle/assistant/DoormileAI/AIOrderForm.js
Normal 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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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' }]
|
||||
|
||||
306
src/pages/nearle/assistant/RAG_PLAN.md
Normal file
306
src/pages/nearle/assistant/RAG_PLAN.md
Normal 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.
|
||||
192
src/pages/nearle/assistant/actions.js
Normal file
192
src/pages/nearle/assistant/actions.js
Normal 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}` : ''}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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}`)]
|
||||
};
|
||||
}
|
||||
|
||||
145
src/pages/nearle/assistant/orderActions.js
Normal file
145
src/pages/nearle/assistant/orderActions.js
Normal 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
330
src/pages/nearle/orders/CreateOrder.css
Normal file
330
src/pages/nearle/orders/CreateOrder.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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} />}
|
||||
|
||||
@@ -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'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user