diff --git a/src/components/nearle_components/PageLayout.js b/src/components/nearle_components/PageLayout.js index 0aa7094..c8a7b3d 100644 --- a/src/components/nearle_components/PageLayout.js +++ b/src/components/nearle_components/PageLayout.js @@ -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 }) { } `} )} - + {children} @@ -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 }; // --------------------------------------------------------------------------- diff --git a/src/components/updateNetworkStatus.js b/src/components/updateNetworkStatus.js index 2814069..ea0f209 100644 --- a/src/components/updateNetworkStatus.js +++ b/src/components/updateNetworkStatus.js @@ -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', diff --git a/src/globalPolish.css b/src/globalPolish.css index c13f0d5..565c244 100644 --- a/src/globalPolish.css +++ b/src/globalPolish.css @@ -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; } diff --git a/src/pages/api/doormileApi.js b/src/pages/api/doormileApi.js index 37de9d2..215efbe 100644 --- a/src/pages/api/doormileApi.js +++ b/src/pages/api/doormileApi.js @@ -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) => { diff --git a/src/pages/nearle/assistant/CLAUDE.md b/src/pages/nearle/assistant/CLAUDE.md index b6a6d27..9a18d2c 100644 --- a/src/pages/nearle/assistant/CLAUDE.md +++ b/src/pages/nearle/assistant/CLAUDE.md @@ -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. diff --git a/src/pages/nearle/assistant/DoormileAI.css b/src/pages/nearle/assistant/DoormileAI.css index ac758f9..4f533fd 100644 --- a/src/pages/nearle/assistant/DoormileAI.css +++ b/src/pages/nearle/assistant/DoormileAI.css @@ -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; +} diff --git a/src/pages/nearle/assistant/DoormileAI/AICustomerForm.js b/src/pages/nearle/assistant/DoormileAI/AICustomerForm.js new file mode 100644 index 0000000..0888f29 --- /dev/null +++ b/src/pages/nearle/assistant/DoormileAI/AICustomerForm.js @@ -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 ( + + + {[values.firstname, values.lastname].filter(Boolean).join(' ')} · {values.phone} + + + {isDone ? '✓ Created' : status === 'cancelled' ? 'Cancelled — nothing was sent.' : 'Expired — ask again to recreate this.'} + + + ); + } + + return ( + + + + + + {touched.firstname && errors.firstname && First name — {errors.firstname}} + + + {touched.phone && errors.phone && Mobile — {errors.phone}} + + + + {/* Address search fills suburb / city / state / postcode and the + coordinates in one go, the same way the create-customer page does. */} + + setValues((prev) => ({ ...prev, address: place.formatted_address || prev.address, ...fromPlace(place) })) + } + disabled={isBusy} + /> + + + + + + + + + + + + + + + + + + Name and mobile are required. The create endpoint documents only name, phone and email — the address is sent too, but may not be + stored. + + + {status === 'failed' && error && {error}} + + +