From cf4f4ecd9c09f9f39b989878cf44caa3b15551dc Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 26 Aug 2026 15:01:15 +0530 Subject: [PATCH] initial commit --- package-lock.json | 13 + package.json | 1 + src/App.jsx | 1 + src/api/doormile/client.js | 34 +- src/api/doormile/endpoints.js | 39 +- src/api/doormile/queries.js | 258 +- src/components/assistant/CLAUDE.md | 285 ++ src/components/assistant/DoormileAI.css | 192 +- .../assistant/DoormileAI/AIComposer.jsx | 12 +- .../assistant/DoormileAI/AIFlowStep.jsx | 10 +- .../assistant/DoormileAI/AIMessage.jsx | 20 +- .../assistant/DoormileAI/AIPanel.jsx | 61 +- .../assistant/DoormileAI/AIParts.jsx | 26 +- .../assistant/DoormileAI/AIRowsStep.jsx | 12 +- .../assistant/DoormileAI/AIWelcome.jsx | 6 +- src/components/assistant/DoormileAI/index.jsx | 29 +- .../assistant/DoormileAI/pageContext.jsx | 30 +- src/components/assistant/DoormileAI/shim.jsx | 139 - src/components/assistant/RAG_PLAN.md | 306 +++ src/components/assistant/ROADMAP.md | 390 +++ src/components/assistant/actions.js | 203 ++ src/components/assistant/assignActions.js | 252 ++ src/components/assistant/assignFlow.js | 200 ++ src/components/assistant/bulkFile.js | 223 ++ src/components/assistant/bulkFlow.js | 193 ++ src/components/assistant/bulkOrderActions.js | 299 +++ src/components/assistant/customerFlow.js | 142 + src/components/assistant/flowEngine.js | 83 + src/components/assistant/intents.js | 2310 +++++++++++++++++ src/components/assistant/orderActions.js | 152 ++ src/components/assistant/orderFlow.js | 267 ++ src/components/assistant/ragRouter.js | 66 + src/components/assistant/repeatFlow.js | 30 + src/components/assistant/repeatRuns.js | 244 ++ .../doormile/AddressAutocomplete.jsx | 333 ++- src/components/ds/StatusBadge.jsx | 3 +- src/components/third-party/ReactTable.jsx | 39 +- src/globalPolish.css | 326 +++ src/layouts/AdminLayout.jsx | 16 +- src/lib/assistant/CLAUDE.md | 285 ++ src/lib/assistant/DoormileAI.css | 1185 +++++++++ src/lib/assistant/RAG_PLAN.md | 306 +++ src/lib/assistant/ROADMAP.md | 390 +++ src/lib/assistant/actions.js | 2 +- src/lib/assistant/assignActions.js | 24 +- src/lib/assistant/assignFlow.js | 4 +- src/lib/assistant/bulkFile.js | 2 +- src/lib/assistant/bulkFlow.js | 6 +- src/lib/assistant/bulkOrderActions.js | 4 +- src/lib/assistant/intents.js | 51 +- src/lib/assistant/orderActions.js | 6 +- src/lib/assistant/orderFlow.js | 9 +- src/lib/assistant/ragRouter.js | 2 +- src/lib/assistant/repeatRuns.js | 6 +- src/lib/assistant/scan.js | 73 +- src/lib/orderStatusGroups.js | 13 +- src/lib/query-client.js | 2 + src/main.jsx | 4 + src/pages/doormile/clients/Tenants.jsx | 23 +- src/pages/doormile/deliveries/Deliveries.jsx | 27 +- src/pages/doormile/dispatch/ActiveSection.jsx | 331 +-- src/pages/doormile/dispatch/Dispatch.css | 2 - src/pages/doormile/dispatch/Dispatch.jsx | 299 ++- src/pages/doormile/dispatch/Preview.jsx | 682 ++--- .../dispatch/ProfitabilitySection.jsx | 549 ++-- src/pages/doormile/orders/CreateOrder.jsx | 1346 +++++++--- src/pages/doormile/orders/MultipleOrders.jsx | 1347 +++++++--- src/pages/doormile/orders/Orders.jsx | 99 +- src/pages/doormile/riders/Riders.jsx | 23 +- src/themes/dt/status.jsx | 14 +- src/utils/axios.js | 17 + src/utils/batchBucket.js | 72 +- src/utils/bulkOrderColumns.js | 150 ++ src/utils/distance.js | 78 + src/utils/doormileAxios.js | 54 +- src/utils/doormileTimestamp.js | 24 +- src/utils/getColors.js | 20 + src/utils/getShadow.js | 39 + src/utils/locales/en.json | 27 + src/utils/logger.js | 70 +- src/utils/orderStatusGroups.js | 67 + src/utils/password-strength.js | 33 + src/utils/password-validation.js | 21 + src/utils/route-guard/AuthGuard.js | 34 + src/utils/route-guard/GuestGuard.js | 34 + src/utils/session.js | 38 + vite.config.js | 3 + 87 files changed, 12814 insertions(+), 2328 deletions(-) create mode 100644 src/components/assistant/CLAUDE.md delete mode 100644 src/components/assistant/DoormileAI/shim.jsx create mode 100644 src/components/assistant/RAG_PLAN.md create mode 100644 src/components/assistant/ROADMAP.md create mode 100644 src/components/assistant/actions.js create mode 100644 src/components/assistant/assignActions.js create mode 100644 src/components/assistant/assignFlow.js create mode 100644 src/components/assistant/bulkFile.js create mode 100644 src/components/assistant/bulkFlow.js create mode 100644 src/components/assistant/bulkOrderActions.js create mode 100644 src/components/assistant/customerFlow.js create mode 100644 src/components/assistant/flowEngine.js create mode 100644 src/components/assistant/intents.js create mode 100644 src/components/assistant/orderActions.js create mode 100644 src/components/assistant/orderFlow.js create mode 100644 src/components/assistant/ragRouter.js create mode 100644 src/components/assistant/repeatFlow.js create mode 100644 src/components/assistant/repeatRuns.js create mode 100644 src/globalPolish.css create mode 100644 src/lib/assistant/CLAUDE.md create mode 100644 src/lib/assistant/DoormileAI.css create mode 100644 src/lib/assistant/RAG_PLAN.md create mode 100644 src/lib/assistant/ROADMAP.md create mode 100644 src/utils/axios.js create mode 100644 src/utils/bulkOrderColumns.js create mode 100644 src/utils/distance.js create mode 100644 src/utils/getColors.js create mode 100644 src/utils/getShadow.js create mode 100644 src/utils/locales/en.json create mode 100644 src/utils/orderStatusGroups.js create mode 100644 src/utils/password-strength.js create mode 100644 src/utils/password-validation.js create mode 100644 src/utils/route-guard/AuthGuard.js create mode 100644 src/utils/route-guard/GuestGuard.js create mode 100644 src/utils/session.js diff --git a/package-lock.json b/package-lock.json index 9186748..466d972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "recharts": "^3.10.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", + "use-debounce": "^10.1.1", "xlsx": "^0.18.5" }, "devDependencies": { @@ -7472,6 +7473,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz", + "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/package.json b/package.json index f8f5635..aef1808 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "recharts": "^3.10.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", + "use-debounce": "^10.1.1", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/src/App.jsx b/src/App.jsx index 3f7b6f0..f073e37 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -88,6 +88,7 @@ const AuthenticatedApp = () => { order whose id is "create". */} } /> } /> + } /> } /> } /> diff --git a/src/api/doormile/client.js b/src/api/doormile/client.js index 1e1fbdf..87e04a0 100644 --- a/src/api/doormile/client.js +++ b/src/api/doormile/client.js @@ -16,6 +16,37 @@ import axios from 'axios'; export const DOORMILE_TOKEN_KEY = 'doormileToken'; export const DOORMILE_USER_KEY = 'doormileUser'; +/** + * The rest of the signed-in identity, written by Login.jsx because parts of the + * data layer read it straight from storage rather than through context. + * + * Ending a session has to clear these too. They are not the credential, but + * `tenantid` is what decides whether a login is Doormile staff or scoped to one + * client — so a stale one left behind by the previous operator is both their + * identity sitting readable in the browser and a value the next session could + * read before signing in. + */ +export const DOORMILE_SESSION_KEYS = ['authname', 'firstname', 'userid', 'roleid', 'tenantid']; + +/** + * Doormile AI's saved thread and its archive (owned by AIPanel.jsx's + * HISTORY_KEY / CONVERSATIONS_KEY). + * + * These are session data, not a preference: the bot answers about live orders, + * so a thread routinely contains order numbers, customer names and phone + * numbers. Production users are warehouse and dispatch staff, often on a shared + * terminal — leaving the transcript behind hands the next operator the last + * one's work. The panel width is a preference and stays. + */ +export const DOORMILE_ASSISTANT_KEYS = ['doormileBotHistory', 'doormileBotConversations']; + +/** Clears every key that makes up a Doormile session. */ +export const clearStoredSession = () => { + localStorage.removeItem(DOORMILE_TOKEN_KEY); + localStorage.removeItem(DOORMILE_USER_KEY); + [...DOORMILE_SESSION_KEYS, ...DOORMILE_ASSISTANT_KEYS].forEach((key) => localStorage.removeItem(key)); +}; + /** The backend, for every `/admin/*` call the console makes. */ export const DOORMILE_API_URL = 'https://api.doormile.com/api/v1'; @@ -51,8 +82,7 @@ doormileAxios.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401 && !window.location.href.includes('/login')) { - localStorage.removeItem(DOORMILE_TOKEN_KEY); - localStorage.removeItem(DOORMILE_USER_KEY); + clearStoredSession(); window.location.replace('/login'); } diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index f949288..ecc8e91 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -1,4 +1,4 @@ -import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY } from './client'; +import doormileAxios, { DOORMILE_TOKEN_KEY, DOORMILE_USER_KEY, clearStoredSession } from './client'; // The Doormile Express admin API — one named async export per // `api.doormile.com/api/v1/admin/*` endpoint. @@ -34,8 +34,8 @@ export const loginAdmin = async (email, password) => { }; export const logoutAdmin = () => { - localStorage.removeItem(DOORMILE_TOKEN_KEY); - localStorage.removeItem(DOORMILE_USER_KEY); + // Clears the auxiliary identity keys too — see clearStoredSession in client.js. + clearStoredSession(); }; // Drains a paginated list endpoint instead of taking whatever the server's @@ -366,8 +366,37 @@ export const getMilerActivity = async (id, from, to) => { // ==============================|| Bookings ||============================== // export const getBookings = async (pageno, pagesize) => { - const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`); - return response.data.data; + // If caller specifically requested page > 1 with small page size: + if (pageno > 1 && pagesize && pagesize < 100) { + const response = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno, pagesize })}`); + return response.data.data || []; + } + + // The backend hard-caps pagesize at 100 per request. + // Drain all pages so no bookings are silently lost. + const firstRes = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno: 1, pagesize: 100 })}`); + const body = firstRes.data || {}; + const firstRows = body.data || []; + const total = Number.isFinite(Number(body.total)) ? Number(body.total) : firstRows.length; + + if (firstRows.length >= total || firstRows.length < 100) { + return firstRows; + } + + const allRows = [...firstRows]; + const totalPages = Math.ceil(total / 100); + for (let p = 2; p <= totalPages; p++) { + try { + const res = await doormileAxios.get(`/admin/bookings${buildQuery({ pageno: p, pagesize: 100 })}`); + const rows = res.data?.data || []; + if (!rows.length) break; + allRows.push(...rows); + if (allRows.length >= total) break; + } catch { + break; + } + } + return allRows; }; // Same request as getBookings, but keeps the envelope's `total`/`page` instead diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index 5740b10..19ade80 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -80,20 +80,48 @@ import { // left Picked permanently empty — nothing else in the enum maps to it. const BOOKING_STATUS_TO_DELIVERY_STATUS = { pending_pickup: 'pending', + pending_assignment: 'pending', + pending: 'pending', miler_assigned: 'pending', pickup_scheduled: 'accepted', + arrived: 'arrived', converted_to_consignment: 'picked', + collected_by_miler: 'picked', + picked: 'picked', out_for_delivery: 'active', + in_transit: 'active', + intransit: 'active', + active: 'active', delivered: 'delivered', - cancelled: 'cancelled' + completed: 'delivered', + cancelled: 'cancelled', + canceled: 'cancelled', + skipped: 'skipped' }; + // Exported (not just module-local) so anything else that needs to classify a // raw booking status — the operator bot's status-breakdown intent included — // reuses this instead of growing a second copy of BOOKING_STATUS_TO_DELIVERY_STATUS // that can drift from it. -export const mapBookingStatusToDeliveryStatus = (status) => { - const key = String(status || '').toLowerCase(); - return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key; +// Supports both (status, reachedAt) and a booking/consignment row object. +export const mapBookingStatusToDeliveryStatus = (statusOrRow, maybeReachedAt) => { + if (statusOrRow && typeof statusOrRow === 'object') { + const b = statusOrRow; + const rawConsignmentStatus = b.consignmentstatus ?? b.consignment_status; + if (rawConsignmentStatus) { + return mapBookingStatusToDeliveryStatus(rawConsignmentStatus); + } + const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time; + const rawStatus = b.status ?? b.orderstatus ?? b.bookingstatus; + return mapBookingStatusToDeliveryStatus(rawStatus, reached); + } + + const raw = String(statusOrRow || '').trim().toLowerCase(); + if (raw === 'pickup_scheduled') { + const isReached = maybeReachedAt != null && maybeReachedAt !== false && maybeReachedAt !== ''; + return isReached ? 'arrived' : 'accepted'; + } + return BOOKING_STATUS_TO_DELIVERY_STATUS[raw] || raw; }; // A consignment's own status, when this booking has one and it looks like a @@ -105,7 +133,7 @@ const consignmentStatusFor = (booking, consignmentMap) => { if (!booking?.consignmentid || !consignmentMap?.size) return undefined; const record = consignmentMap.get(String(booking.consignmentid)); if (!record) return undefined; - const raw = record.status ?? record.consignmentstatus ?? record.currentstatus; + const raw = record.status ?? record.consignmentstatus ?? record.currentstatus ?? record.consignment_status; return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; }; @@ -627,105 +655,90 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { const miler = milerMap.get(b.assignedmileruserid); const tenant = tenantMap.get(b.tenantid); const charge = b.serviceoptions?.[0]?.estimatedprice; - return { - orderheaderid: b.bookingid, - deliveryid: b.bookingid, - orderid: b.bookingno || `#${b.bookingid}`, - consignmentid: b.consignmentid, - tenantid: b.tenantid, - tenantname: tenant?.tenantname || '', - tenantsuburb: '', - applocation: '', - tenantadress: tenant?.primaryemail || '', - locationname: tenant?.tenantname || '', - locationsuburb: '', - // Was hardcoded '' — Dispatch.js's kitchen markers read this as the - // pickup business name (`o.pickupcustomer || o.kitchen_key || 'Unknown'`, - // Dispatch.js:1660), and no booking on this API carries a `kitchen_key` - // field at all, so every kitchen pin fell through to the literal string - // 'Unknown' — rendered as a "U" marker whose hover/popup then showed - // "Unknown". The tenant IS the kitchen for a B2B booking (same value - // already used for tenantname/locationname above), so reuse it here. - pickupcustomer: tenant?.tenantname || '', - pickupcontactno: '', - Pickupaddress: b.pickupaddress || '', - pickupaddress: b.pickupaddress || '', - pickuplocation: b.pickupaddress || '', - pickupsuburb: '', - deliverycustomer: customer?.firstname || customer?.name || (b.appcustomerid ? `Customer #${b.appcustomerid}` : ''), - deliverycontactno: customer?.phone || customer?.contactno || '', - deliveryaddress: b.deliveryaddress || '', - deliverylocation: b.deliveryaddress || '', - deliverysuburb: '', - ridername: miler?.displayname || (b.assignedmileruserid ? `Rider #${b.assignedmileruserid}` : ''), - userid: b.assignedmileruserid, - // GET /admin/milers/:id/notify (and block/assign-vehicle) key off - // milerprofileid, not the userid stored on the booking — confirmed - // live (a booking's assignedmileruserid matches a miler's `userid` - // field, which 404s against /admin/milers/:id; milerprofileid is the - // real primary key of that resource). - milerprofileid: miler?.milerprofileid, - ridercontact: miler?.phone || '', - expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat, - // No route-plan data source (step order/transit time/cumulative km were - // computed by the old jupiter backend from the dispatch optimiser's - // output, not stored on a booking/consignment) — left undefined so the - // UI's own "—" fallbacks render instead of a fabricated number. - transitminutes: undefined, - cumulativekms: undefined, - step: undefined, - // No road-distance field on a booking — approximated as a straight - // line between pickup and delivery coordinates. - kms: haversineKm(b.pickuplatitude, b.pickuplongitude, b.deliverylatitude, b.deliverylongitude), - pickuplatitude: b.pickuplatitude, - pickuplongitude: b.pickuplongitude, - deliverylatitude: b.deliverylatitude, - deliverylongitude: b.deliverylongitude, - deliverycharges: charge, - deliveryamt: charge, - deliveryamount: charge, - Quantity: b.parcels?.length || 0, - quantity: b.parcels?.length || 0, - collectionamt: undefined, - notes: b.notes || '', - deliverytype: customer ? 'B' : 'C', - orderdate: b.createdat, - deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat, - // ⚠ NOT a real assignment time. The Doormile bookings feed has no - // assignment timestamp (the true one lives on `bookingassignments`, - // reachable only per-booking via GET /admin/bookings/:id/track), so this - // is the booking's last-modified column. It moves every time ANYTHING - // touches the row — status change, parcel scan, payment, pickup-complete. - // - // **Never bucket or group by this field.** Dispatch.js and deliveries.js - // used to bucket their Morning/Afternoon/Evening batches on it, which - // meant an order re-stamped during the evening silently jumped out of the - // batch it was actually assigned to and into whichever window contained - // the current clock time — the same orders appearing under Afternoon and - // then Evening on the same day. Both now bucket on `orderdate` (this - // row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's - // table for why `expecteddeliverytime` was also tried and rejected (it's - // the promised delivery slot, not the wave the order was placed in). It - // remains fine to DISPLAY assigntime as a "last updated" stamp, which is - // all the reports use it for. - assigntime: b.updatedat, - // The consignment's status WINS when there is one. That is the record - // the rider app and the Update Status dialog both advance; the booking's - // status is frozen at Converted_To_Consignment from pickup onwards. - // Falls back to the booking whenever the consignment is absent or carries - // nothing status-shaped — never invents a state. - orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status), - // Which record the status above came from, and what it said. A booking - // freezes at Converted_To_Consignment the moment it is picked up, so a - // row can legitimately read "Active" while GET /admin/bookings still says - // Converted_To_Consignment — which looks exactly like a bug unless the UI - // can say where the value came from. - consignmentstatus: consignmentStatusFor(b, consignmentMap), - statusfromconsignment: consignmentStatusFor(b, consignmentMap) != null, - droplat: b.deliverylatitude, - droplon: b.deliverylongitude - }; - }); + const cStatus = consignmentStatusFor(b, consignmentMap); + const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time; + const effectiveDeliveryStatus = cStatus + ? mapBookingStatusToDeliveryStatus(cStatus) + : mapBookingStatusToDeliveryStatus(b.status, reached); + + return { + orderheaderid: b.bookingid, + deliveryid: b.bookingid, + orderid: b.bookingno || `#${b.bookingid}`, + consignmentid: b.consignmentid, + tenantid: b.tenantid, + tenantname: tenant?.tenantname || '', + tenantsuburb: '', + applocation: '', + tenantadress: tenant?.primaryemail || '', + locationname: tenant?.tenantname || '', + locationsuburb: '', + // Was hardcoded '' — Dispatch.js's kitchen markers read this as the + // pickup business name (`o.pickupcustomer || o.kitchen_key || 'Unknown'`, + // Dispatch.js:1660), and no booking on this API carries a `kitchen_key` + // field at all, so every kitchen pin fell through to the literal string + // 'Unknown' — rendered as a "U" marker whose hover/popup then showed + // "Unknown". The tenant IS the kitchen for a B2B booking (same value + // already used for tenantname/locationname above), so reuse it here. + pickupcustomer: tenant?.tenantname || '', + pickupcontactno: '', + Pickupaddress: b.pickupaddress || '', + pickupaddress: b.pickupaddress || '', + pickuplocation: b.pickupaddress || '', + pickupsuburb: '', + deliverycustomer: customer?.firstname || customer?.name || (b.appcustomerid ? `Customer #${b.appcustomerid}` : ''), + deliverycontactno: customer?.phone || customer?.contactno || '', + deliveryaddress: b.deliveryaddress || '', + deliverylocation: b.deliveryaddress || '', + deliverysuburb: '', + ridername: miler?.displayname || (b.assignedmileruserid ? `Rider #${b.assignedmileruserid}` : ''), + userid: b.assignedmileruserid, + // GET /admin/milers/:id/notify (and block/assign-vehicle) key off + // milerprofileid, not the userid stored on the booking — confirmed + // live (a booking's assignedmileruserid matches a miler's `userid` + // field, which 404s against /admin/milers/:id; milerprofileid is the + // real primary key of that resource). + milerprofileid: miler?.milerprofileid, + ridercontact: miler?.phone || '', + expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat, + // No route-plan data source (step order/transit time/cumulative km were + // computed by the old jupiter backend from the dispatch optimiser's + // output, not stored on a booking/consignment) — left undefined so the + // UI's own "—" fallbacks render instead of a fabricated number. + transitminutes: undefined, + cumulativekms: undefined, + step: undefined, + // No road-distance field on a booking — approximated as a straight + // line between pickup and delivery coordinates. + kms: haversineKm(b.pickuplatitude, b.pickuplongitude, b.deliverylatitude, b.deliverylongitude), + pickuplatitude: b.pickuplatitude, + pickuplongitude: b.pickuplongitude, + deliverylatitude: b.deliverylatitude, + deliverylongitude: b.deliverylongitude, + deliverycharges: charge, + deliveryamt: charge, + deliveryamount: charge, + Quantity: b.parcels?.length || 0, + quantity: b.parcels?.length || 0, + collectionamt: undefined, + notes: b.notes || '', + deliverytype: customer ? 'B' : 'C', + orderdate: b.createdat, + deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat, + reachedat: reached, + assigntime: b.updatedat, + // The consignment's status WINS when there is one (e.g. Collected_By_Miler -> Picked, + // Out_for_Delivery -> Active, Delivered -> Delivered). + // Otherwise derives from booking status + reachedat: + // Pickup_Scheduled + reachedat present -> Arrived + // Pickup_Scheduled + reachedat absent -> Accepted + orderstatus: effectiveDeliveryStatus, + consignmentstatus: cStatus, + statusfromconsignment: cStatus != null, + droplat: b.deliverylatitude, + droplon: b.deliverylongitude + }; + }); // Apply the requested date range to the booking's CREATION day. This has to // agree with what the batch bucketing reads (Dispatch.js's @@ -739,16 +752,6 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // // A missing/blank bound means "unbounded on that side", which preserves the // old behaviour for any caller that doesn't pass real dates. - // - // ⛔ An "activity" basis — also admitting a row whose `assigntime` - // (== `updatedat`) falls in the window — was tried and REVERTED. It let an - // order created yesterday evening and merely touched today onto today's - // board, but batch bucketing reads `orderdate`, so that row landed in - // Evening Batch. The live result was "Evening 6" at 10:41 in the morning on a - // day with no orders created at all. Admitting a row on one timestamp while - // bucketing it on another cannot produce an honest batch count; if - // carried-over work needs to be visible it needs its own bucket, not a - // time-of-day wave it does not belong to. const inRange = (row) => { if (!startdate && !enddate) return true; const t = parseDoormileTimestamp(row.orderdate); @@ -761,15 +764,6 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { return { rows: rows.filter(inRange), - // Whether to fetch another page must be based on the RAW bookings page - // (bookings.length), not the post-filter `rows.length` — most bookings on - // any given page are still pending, not dispatched, so the filtered count - // almost never equals rowsPerPage. Comparing the filtered count against - // rowsPerPage (the previous logic) made pagination stop after page 1 in - // virtually every real dataset, silently hiding dispatched/assigned - // orders that live beyond the first `rowsPerPage` bookings — e.g. a - // booking just created and assigned wouldn't show on the Deliveries page - // at all once the tenant has more than one page's worth of bookings. nextPage: (bookings || []).length === Number(rowsPerPage) ? pageParam + 1 : undefined }; }; @@ -780,11 +774,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // match what the table actually showed. const getDeliveryStatusCounts = async () => { try { - const bookings = (await getBookings(1, 1000)) || []; - const dispatched = bookings.filter((b) => b.assignedmileruserid || b.consignmentid); + const [bookings, consignments] = await Promise.all([ + getBookings(1, 1000).catch(() => []), + getConsignments().catch(() => []) + ]); + const consignmentMap = new Map(); + (consignments || []).forEach((c) => { + const id = c?.consignmentid ?? c?.id; + if (id != null) consignmentMap.set(String(id), c); + }); + const dispatched = (bookings || []).filter((b) => b.assignedmileruserid || b.consignmentid); const counts = { total: dispatched.length }; dispatched.forEach((b) => { - const status = mapBookingStatusToDeliveryStatus(b.status); + const cStatus = consignmentStatusFor(b, consignmentMap); + const reached = b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time; + const status = cStatus + ? mapBookingStatusToDeliveryStatus(cStatus) + : mapBookingStatusToDeliveryStatus(b.status, reached); counts[status] = (counts[status] || 0) + 1; }); return counts; diff --git a/src/components/assistant/CLAUDE.md b/src/components/assistant/CLAUDE.md new file mode 100644 index 0000000..390062d --- /dev/null +++ b/src/components/assistant/CLAUDE.md @@ -0,0 +1,285 @@ +# CLAUDE.md — `src/pages/nearle/assistant/` + +Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `DoormileAI/`). Read this before touching either. + +--- + +## 1. What this is + +An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **Doormile AI**, subtitle **Operations Copilot**. Not a standalone page: it lives as a **right-side slide-over** opened from a header icon. + +- **Mounted in**: `src/layout/MainLayout/AppTopNav.js` — a single ``. There is **no route and no sidebar entry** for this feature — don't add one back. If you're tempted to give it a full page, re-read §2 first; that was tried and deliberately reverted. +- **`intents.js`** — all data logic: the intent catalog, keyword/phrase matching, and the API calls that produce answers. The UI never fetches. +- **`DoormileAI/`** — all UI: + - `index.js` — the trigger button; owns open/closed state and returns focus to itself on close. + - `AIPanel.js` — the portal, scrim, slide-over, focus/Escape handling, message state, history persistence, and the ask() flow. + - `AIWelcome.js` — greeting + suggestion cards (empty-thread state only). + - `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT. + - `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline. + - `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5). + - `AIBulkOrderForm.js` — the one create that stays a form (CSV paste). + - `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock. + - `pageContext.js` — route → context label + suggested questions. +- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`). + +### UI rules that are load-bearing, not cosmetic + +- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot. +- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message. +- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element. +- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could. +- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components. +- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be. +- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too. +- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding. + +--- + +## 2. Why this is deterministic, not LLM-based + +This was a deliberate, explicit product decision (not a technical limitation worked around silently): **this app has zero backend of its own** — confirmed exhaustively (no `server/`, no Firebase Cloud Functions, no `firebase-admin`/`firebase-functions` dependency, `Dockerfile` just serves a static CRA build via nginx). An LLM call needs an API key held server-side; there is nowhere in this repo's infrastructure to put one without shipping it to the browser. + +Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no. + +### RAG — rejected for the data, later built for the ROUTING + +RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.** + +What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced: + +``` +question → embed → Chroma → intentId + confidence → the SAME run() → live API call +``` + +Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase. + +Three rules that must hold: + +- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today. +- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values. +- **Write intents need high confidence.** A semantic near-miss must never open a create form. + +--- + +## 3. The intent pattern (`intents.js`) + +Each entry in `INTENTS` is: + +```js +{ + id: 'someIntent', + label: 'Human-readable description — e.g. "example phrasing"', + match: (text) => params | null, // does this intent apply? extract params or refuse + run: async (params) => ({ headline, detail, sourceCalls }) | null // real answer, or "couldn't resolve" +} +``` + +- `answerQuestion(text)` walks `INTENTS` **in order** and returns the first intent whose `match` recognises the text **and** whose `run` resolves to a non-null result. `run` returning `null` means "the pattern matched but couldn't be resolved" (e.g. no tenant name in the question actually matched a real tenant) — the loop falls through to the next intent rather than answering with a guess. +- `sourceCalls` feeds `` behind the per-message "Sources" disclosure in `AIMessage.js` — every answer can still show which API was queried and what came back, so an operator can verify it wasn't invented. It is collapsed by default for visual quiet; **do not remove it**, that disclosure is the verifiability contract. +- `metric` and `stats` (optional) drive the panel's headline number and breakdown grid. `stats` comes from `statusStats()`, which tallies the *same* `mapBookingStatusToDeliveryStatus` classification as the prose `headline`, so the sentence and the cards can never disagree. +- Every `run` calls a real function from `pages/api/api.js` / `pages/api/doormileApi.js`. **Never fabricate a number** — if no existing function covers a question, either add a new intent that calls a real endpoint, or leave the question unanswered (falls through to the "I can't answer that one yet" state in `AIPanel.js`). A wrong number from this bot is worse than no answer. + +### Coverage grows with the console, not ahead of it + +The catalog covers orders/bookings, riders, tenants, and the fleet/ops resources (hubs, vehicles, tripsheets, exceptions, app users, customers, pricing, consignments, partners, competitor branches, carrier pricing). When a new admin resource gets its own page in this console, add a matching intent here too — and **always call the exact same `getX()` function that page's own table already calls** (e.g. `hubStatus` calls `getHubs()`, the same function `hubs.js` uses). Never write a bespoke fetch for the bot. This is what keeps the bot's numbers live and in agreement with what the corresponding page shows — the whole point of not hand-rolling a separate data path. + +### Composite questions — `orderQuery` + +`orderQuery` (ordered 3rd, ahead of `riderCounts`) is the one intent that composes filters: status x batch x tenant x rider, plus rankings ("top 5 tenants by orders"). Everything else in the catalog answers exactly one dimension and discards the rest of the sentence. + +**It claims a question only when two or more of status/batch/tenant/rider are present, or a ranking is asked for.** A date is deliberately NOT counted as a dimension — every intent already handles dates, and counting it re-routed four working questions ("how many cancelled orders today") away from the intents that answer them better. If you widen this matcher, re-run the routing probe first; over-claiming here silently changes answers across the whole catalog. + +`run` returns `null` when a named tenant or rider doesn't resolve, so an unrecognised name falls through rather than having its filter silently dropped — which is the exact bug this intent exists to fix. + +Entity names resolve through `bestNameMatch`, which is bidirectional (the question may name a shorter or longer form than the record) and prefers the longest match, so "Acme" can't beat "Acme Foods" when both exist. + +### Beyond single-question matching + +A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, all still deterministic (no LLM): + +- **Typo tolerance** — `correctTypos()` runs once before matching, correcting misspelled domain keywords (length ≥5, Levenshtein distance ≤1/≤2) against a fixed `KEYWORD_VOCAB`. It never touches order IDs, tenant names, or short words — only known keywords get "corrected," so it can't invent a wrong one. +- **Richer dates** — `explicitDateFromWords` (DD/MM/YYYY, ISO), `weekdayFromWords` (most recent past occurrence of a named day), and `rangeFromWords` (this/last week, this/last month, explicit "from X to Y") feed `dayFromWords`/`rangeFromWords`. Still a fixed vocabulary, not a date-parsing library — an unrecognised phrase falls back to today, never a guessed date. +- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue". +- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched. +- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up. +- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale. +- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all. +- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent. + +### Ordering and cross-domain guards — read before adding an intent + +A real bug shipped here once: `statusBreakdown` matched the word "active" (a valid order status), so "how many riders are active today" was swallowed by the order-status intent and called `getBookings` instead of `getallridersummary` — because `statusBreakdown` sat earlier in `INTENTS` than `riderCounts` and its `run` never returns `null` (it always finds *some* count, even 0), so it never yielded. + +The fix, and the rule going forward: + +1. **Domain-specific intents (rider, tenant) are ordered near the top**, ahead of the generic order/status/date intents, so an unambiguous keyword like "rider" always wins first-match. +2. **Generic intents explicitly refuse to match on another domain's keyword**, via helper guards like `mentionsRiders(text)` at the top of their `match`. This is deliberately redundant with (1) — if someone reorders `INTENTS` later without noticing the significance, the guards still hold. + +If you add a new intent whose trigger words could plausibly appear in an unrelated intent's question (status words, "for", generic nouns), do both: place it appropriately in the order, and add a guard to anything downstream it could shadow — don't rely on ordering alone. + +### Date/batch/status vocabulary — reuse, don't reinvent + +- **Batch bucketing** (`morning`/`afternoon`/`evening`) comes from `src/utils/batchBucket.js`, extracted from `Dispatch.js`/`deliveries.js`'s canonical model (see `dispatch/CLAUDE.md` §1). Bucketing on anything other than `orderdate` (a booking's `createdat`) will disagree with what those two pages show — don't reintroduce `expecteddeliverytime`/`assigntime` bucketing here, they were both tried and rejected for the same reasons documented there. +- **Order status classification comes from `utils/orderStatusGroups.js`**, which is the SAME match set the Orders page's tabs count with (`orders.js` imports `statusesInGroup` for its `ORDERS_STATUS_TABS`). Use `groupForBookingStatus` / `isInGroup` / `statusesInGroup`; don't grow a third definition. + - It is deliberately **not** `mapBookingStatusToDeliveryStatus` (api.js), which is the *Deliveries* page's rider-centric taxonomy and keeps `miler_assigned` on `pending`. The two exist on purpose — Orders tracks the operator's action, Deliveries tracks the rider's. Don't merge them; that was tried and reverted per explicit product direction. + - The assistant answers order-status questions with the ORDERS taxonomy because that is the screen an operator compares its answers against. A live bug came from the mismatch: the Orders page showed 19 Assigned while the bot said 0. +- **Date words** are a fixed, small vocabulary — not a real date-parsing library. Don't guess at "the 5th" style phrasing; an unrecognised date phrase falls back to today rather than to a wrong date. +- **State questions vs flow questions — do not default a state question to today.** `mentionsAnyDate(text)` distinguishes "the question named a date" from "we defaulted to one": + - *State* ("how many orders are assigned / cancelled") describes the queue **right now** and must be unscoped, because the Orders page's tabs apply no date filter either. Scoping it to orders *created today* is what made the bot answer 0 against a page showing 19. + - *Flow* ("how many orders today", revenue, batches) genuinely needs a period and keeps the today default. + When a state question is answered unscoped, say so in the detail — the answer must never leave the operator guessing which window it covered. +- `GET /admin/bookings` has no server-side date/status/tenant filter, so every intent fetches and filters client-side. It does **not** fetch a single page: `pagesize` is capped at 1000 server-side, so a lone `getBookings(1, 1000)` silently under-reports the moment an account passes 1000 lifetime bookings. Use `fetchBookingsInRange(start, end)` / `fetchBookingsForDay(day)` / `scanBookings()`, which drain pages via `getBookingsPage` up to `MAX_PAGES` and return `{ rows, truncated, scanned, pagesFetched, total }`. +- **`truncated` is not optional to handle.** If you write a new intent, run its count through `countPhrase(scan, n)` ("At least 42"), append `truncationNote(scan)` to the detail, and build its audit entry with `scanCall(scan, ...)` — which reports `status: 'error'` when capped so the tool-call strip can't show a green "complete" beside a partial number. An intent that reads `scan.rows` and ignores `scan.truncated` reintroduces exactly the bug this replaced. +- **Revenue excludes cancelled orders and is labelled "estimated"** — `revenueOf(rows)` sums every `serviceoptions[].estimatedprice` on non-cancelled rows. It is a quote, not a settled amount; don't relabel it "revenue" flat. +- **"Assigned" does not go through the coarse bucket.** `mapBookingStatusToDeliveryStatus` collapses `miler_assigned` into `pending` alongside `pending_pickup` (orders with no rider at all), so `rawStatusFromWords` matches the backend enum directly. Any other question naming a raw enum should do the same rather than being forced into a delivery-status bucket. + +--- + +### Customer creation writes to `/admin/tenantcustomers` + +Settled by evidence, not by reading the docs: + +``` +POST /admin/customers → 405 Method Not Allowed (confirmed live) +``` + +405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.** + +**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.) + +**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately. + +Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change. + +The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks. + +Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery. + +The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree. + +--- + +## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js` + +Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable. + +**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`. + +### The panel drives the conversation, not the router + +`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router. + +Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer. + +### One engine, three flows + +The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker. + +Step entries carry: + +| key | meaning | +|---|---| +| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. | +| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. | +| `type: 'text'` | answered through the composer. | +| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). | +| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. | +| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. | +| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. | +| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. | + +### Two silent-NaN traps that were live + +- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value. +- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was. + +`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate. + +### Bulk — a conversation, then one long pass + +Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient. + +**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked. + +**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept. + +**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column. + +### One row pipeline, two inputs + +A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else. + +**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column. + +`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row. + +**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional: +- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely. +- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197. +- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup. + +**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose. + +**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded. + +Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated. + +### Repeat Runs — `repeatRuns.js` / `repeatFlow.js` + +"Same orders as yesterday." One question (which day), then a pass, then the usual gate. + +**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply. + +**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination: + +| Trap | Seen on | +|---|---| +| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key | +| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` | +| pickup pincode no longer served | CityGate refuses at the middleware, before the handler | + +Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale. + +**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key. + +**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number. + +**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders. + +Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round". + +### Assigning a rider — `assignActions.js` / `assignFlow.js` + +The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run. + +**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced. + +**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record. + +**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs. + +**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions. + +Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed. + +Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path. + +--- + +## 4. What's deliberately out of scope right now + +- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite. +- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives. +- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built. +- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started. +- **Automated tests for the intent matcher.** The matcher is pure functions (`match`/`run` per intent) and would be straightforward to unit-test, but the project's stated convention is "no tests of consequence, lint is the only gate" (root `CLAUDE.md`). Adding a test framework here is a scope decision for the user, not something to introduce silently. + +--- + +## 5. Don'ts + +- Don't re-add a route/sidebar entry for this feature — it's a header slide-over, not a page. +- Don't let a new intent's `match` fire without considering what other intents' trigger words it might contain (see §3's ordering rule). +- Don't bucket batches or classify statuses with page-local logic — reuse `utils/batchBucket.js` and `mapBookingStatusToDeliveryStatus`. +- Don't answer with a number that didn't come from `sourceCalls`-tracked real data — including anything rendered into a `metric` or `stats` card. +- Don't surface a raw API error string to the operator. Errors log to `console.error` and render as the polished error state; the toast that used to leak `err.response.data.message` is gone. diff --git a/src/components/assistant/DoormileAI.css b/src/components/assistant/DoormileAI.css index 884c938..745eed3 100644 --- a/src/components/assistant/DoormileAI.css +++ b/src/components/assistant/DoormileAI.css @@ -45,24 +45,32 @@ } .dai-root { - --dai-accent: #C8102E; + /* Brand accent — resolves to the app's accent token, which is black + (root CLAUDE.md §6.2: "the brand is black", one accent only). Send + button, active states and the trigger all read from this single + variable, so switching the assistant to Doormile red is a one-line + change here rather than a hunt through the components. */ + --dai-accent: var(--color-accent, #0f172a); --dai-accent-contrast: #ffffff; - --dai-accent-ring: rgba(200, 16, 46, 0.16); + /* Focus ring, tinted with the accent rather than a flat grey wash. */ + --dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent); - --dai-ai-from: #C8102E; - --dai-ai-to: #E11D48; - --dai-ai-glow: rgba(200, 16, 46, 0.15); + /* AI identity accent — used ONLY on the spark/orb marks so the assistant + reads as an AI surface without turning the panel into a purple product. */ + --dai-ai-from: #6366f1; + --dai-ai-to: #8b5cf6; + --dai-ai-glow: rgba(99, 102, 241, 0.18); --dai-text: #0f172a; - --dai-text-secondary: #475569; + --dai-text-secondary: #64748b; --dai-text-muted: #94a3b8; --dai-surface: #ffffff; --dai-surface-alt: #f8fafc; --dai-surface-hover: #f1f5f9; - --dai-border: #e2e8f0; - --dai-border-strong: #cbd5e1; + --dai-border: rgba(15, 23, 42, 0.08); + --dai-border-strong: rgba(15, 23, 42, 0.14); --dai-scrim: rgba(15, 23, 42, 0.08); - --dai-shadow: -6px 0 24px rgba(15, 23, 42, 0.06); + --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1); --dai-live: #10b981; --dai-panel-width: 428px; @@ -109,22 +117,16 @@ No radius and no drop shadow: both are what make a surface read as floating ABOVE the page. A soft shadow is kept only as a left-edge falloff so the seam has depth without the panel detaching. */ -/* Two classes, not one: the element is a shadcn SheetContent, which carries - `inset-y-0 h-full` from Tailwind. Those utilities are emitted after this - stylesheet, so a single `.dai-panel` ties on specificity and loses — the - dock then starts at y=0 and covers the top nav it is supposed to sit under. - `.dai-root.dai-panel` out-specifies them; `height: auto` hands the box back - to top/bottom so it cannot run past the bottom of the window. */ -.dai-root.dai-panel { +.dai-panel { position: fixed; - /* Measured from the app header by AIPanel — see the [data-app-header] hook. */ + /* Set from JS by AIPanel — Astryx does not publish a header-height token, + despite --appshell-header-height looking like one. */ top: var(--dai-dock-top, 57px); right: 0; bottom: 0; - height: auto; z-index: 1200; width: var(--dai-dock-width); - display: none; + display: flex; flex-direction: column; min-height: 0; overflow: hidden; @@ -138,20 +140,13 @@ transform var(--dai-duration) var(--dai-ease), visibility 0s linear var(--dai-duration); } - -.dai-panel[data-state='open'], .dai-panel[data-open='true'] { - display: flex !important; transform: translateX(0); visibility: visible; transition: transform var(--dai-duration) var(--dai-ease), visibility 0s; } -.dai-panel[data-state='closed'], -.dai-panel[data-open='false'] { - display: none !important; -} /* ---- The page makes room ------------------------------------------------- `.dai-docked` is set on while the panel is open. Padding rather than @@ -162,10 +157,10 @@ The transition sits on the container unconditionally so the page slides back when the panel closes too — a rule that only exists while `.dai-docked` is applied cannot animate its own removal. */ -.dai-page { +.astryx-layout-content { transition: padding-right var(--dai-duration) var(--dai-ease); } -body.dai-docked .dai-page { +body.dai-docked .astryx-layout-content { padding-right: var(--dai-dock-width); } @@ -174,7 +169,7 @@ body.dai-docked .dai-page { used to be everywhere — a full-width overlay with a scrim — and the page stops reserving a strip it cannot afford. */ @media (max-width: 900px) { - .dai-root.dai-panel { + .dai-panel { top: 0; width: 100%; z-index: 1301; @@ -187,7 +182,7 @@ body.dai-docked .dai-page { .dai-scrim[data-open='true'] { opacity: 1; } - body.dai-docked .dai-page { + body.dai-docked .astryx-layout-content { padding-right: 0; } } @@ -199,59 +194,67 @@ body.dai-docked .dai-page { -------------------------------------------------------------------------- */ .dai-root .dai-header { flex: 0 0 auto; - padding: 14px 16px 12px; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(12px); + padding: 14px 12px 12px 14px; border-bottom: 1px solid var(--dai-border); } .dai-root .dai-title { font-size: 15px; - font-weight: 700; + font-weight: 650; line-height: 1.2; letter-spacing: -0.01em; color: var(--dai-text); } .dai-root .dai-subtitle { font-size: 12px; - font-weight: 500; line-height: 1.3; color: var(--dai-text-muted); } -/* The AI mark — sleek rounded tinted container */ +/* The AI mark. A soft gradient orb — not a robot face. */ .dai-root .dai-spark { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; - border-radius: 9px; - background: #fff1f2; - border: 1px solid #fecdd3; + border-radius: 999px; + background: var(--dai-surface); overflow: hidden; } +/* The mark's own canvas is only 66.8% content — a third of every edge is + transparent padding, measured off the PNG's alpha bounding box (x and y both + 170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore + rendered a D two-thirds the size the box implied, which is exactly why it + read as too small. 150% cancels that padding so the D fills its box edge to + edge; the overflow:hidden above clips only transparent pixels. */ +.dai-root .dai-spark img { + width: 150%; + height: 150%; + max-width: none; + object-fit: contain; + display: block; +} .dai-root .dai-spark[data-size='sm'] { - width: 28px; - height: 28px; + width: 30px; + height: 30px; } .dai-root .dai-spark[data-size='md'] { - width: 36px; - height: 36px; + width: 40px; + height: 40px; } .dai-root .dai-spark[data-size='lg'] { - width: 48px; - height: 48px; + width: 56px; + height: 56px; } -/* Live indicator — crisp pill */ +/* Live indicator — subtle, not a large pill. */ .dai-root .dai-live { display: inline-flex; align-items: center; gap: 5px; - padding: 3px 9px; + padding: 3px 8px; border-radius: 999px; font-size: 11px; - font-weight: 600; + font-weight: 550; color: #047857; - background: #ecfdf5; - border: 1px solid #a7f3d0; + background: rgba(16, 185, 129, 0.08); white-space: nowrap; } .dai-root .dai-live-dot { @@ -275,10 +278,9 @@ body.dai-docked .dai-page { /* Page-context strip — "Orders · Today · All locations" */ .dai-root .dai-context { flex: 0 0 auto; - padding: 8px 16px; + padding: 7px 14px; font-size: 11.5px; - font-weight: 500; - color: var(--dai-text-secondary); + color: var(--dai-text-muted); background: var(--dai-surface-alt); border-bottom: 1px solid var(--dai-border); white-space: nowrap; @@ -367,31 +369,34 @@ body.dai-docked .dai-page { align-items: center; gap: 6px; max-width: 100%; - padding: 6px 14px; + padding: 5px 10px; text-align: left; font: inherit; font-size: 12px; line-height: 1.35; - font-weight: 550; + font-weight: 500; color: var(--dai-text); background: var(--dai-surface); border: 1px solid var(--dai-border); - border-radius: 999px; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + /* 14px, pinned — NOT 999px. + + On a one-line chip a fully round radius already resolves to about 14px, so + these look identical. The difference shows on a chip whose question wraps + to two lines: 999px would resolve to half of ~46px and the chip stops + reading as a chip and starts reading as a card, so one long suggestion + would look like a different component from the nineteen beside it. */ + border-radius: 14px; cursor: pointer; transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, - transform 140ms ease, - box-shadow 140ms ease; + transform 140ms ease; } .dai-suggestion:hover { - background: #fff1f2; - border-color: #fecdd3; - color: #C8102E; + background: var(--dai-surface-alt); + border-color: var(--dai-border-strong); transform: translateY(-1px); - box-shadow: 0 2px 6px rgba(200, 16, 46, 0.1); } .dai-suggestion:active { transform: translateY(0); @@ -406,7 +411,7 @@ body.dai-docked .dai-page { transition: color 140ms ease; } .dai-suggestion:hover .dai-suggestion-icon { - color: #C8102E; + color: var(--dai-text-secondary); } .dai-root .dai-suggestion-text { min-width: 0; @@ -497,32 +502,22 @@ body.dai-docked .dai-page { } .dai-root .dai-msg-user { - max-width: 84%; + max-width: 82%; margin-left: auto; - padding: 10px 15px; - border-radius: 18px 18px 4px 18px; + padding: 8px 12px; + border-radius: 5px; + border-bottom-right-radius: 5px; font-size: 13.5px; - font-weight: 500; line-height: 1.45; - color: #ffffff; - background: linear-gradient(135deg, #C8102E 0%, #A00C24 100%); - box-shadow: 0 2px 8px rgba(200, 16, 46, 0.16); + color: var(--dai-accent-contrast); + background: var(--dai-accent); white-space: pre-wrap; overflow-wrap: anywhere; } -/* Assistant — sleek rounded response card */ -.dai-root .dai-msg-row { - background: #f8fafc; - border: 1px solid var(--dai-border); - border-radius: 18px 18px 18px 4px; - padding: 12px 14px; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.03); - max-width: 95%; - width: fit-content; -} +/* Assistant — no bubble. Text sits on the panel surface. */ .dai-root .dai-msg-ai { font-size: 13.5px; - line-height: 1.55; + line-height: 1.5; color: var(--dai-text); overflow-wrap: anywhere; } @@ -534,8 +529,8 @@ body.dai-docked .dai-page { } .dai-root .dai-msg-name { font-size: 11.5px; - font-weight: 700; - color: #334155; + font-weight: 600; + color: var(--dai-text-secondary); } .dai-root .dai-msg-footer { font-size: 11px; @@ -700,23 +695,24 @@ body.dai-docked .dai-page { -------------------------------------------------------------------------- */ .dai-root .dai-composer-wrap { flex: 0 0 auto; - padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px)); + padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--dai-border); background: var(--dai-surface); } .dai-root .dai-composer { - border: 1.5px solid var(--dai-border); - border-radius: 16px; - background: var(--dai-surface-alt); + border: 1px solid var(--dai-border-strong); + /* 14px, the same corner the suggestion chips use. At 5px the input was the + one sharp-cornered thing in a panel of rounded surfaces, and it sat + directly beneath the chips where the mismatch was most visible. */ + border-radius: 14px; + background: var(--dai-surface); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); - padding: 10px 12px 8px 14px; + padding: 10px 10px 8px 14px; transition: border-color 140ms ease, - background-color 140ms ease, box-shadow 140ms ease; } .dai-root .dai-composer[data-focused='true'] { - background: var(--dai-surface); border-color: var(--dai-accent); box-shadow: 0 0 0 3px var(--dai-accent-ring); } @@ -815,27 +811,24 @@ body.dai-docked .dai-page { align-items: center; justify-content: center; flex: 0 0 auto; - width: 32px; - height: 32px; + width: 30px; + height: 30px; padding: 0; border: none; border-radius: 50%; background: var(--dai-accent); color: var(--dai-accent-contrast); cursor: pointer; - box-shadow: 0 2px 6px rgba(200, 16, 46, 0.25); transition: background-color 140ms ease, opacity 140ms ease, - transform 140ms ease, - box-shadow 140ms ease; + transform 140ms ease; } .dai-root .dai-send:hover:not(:disabled) { - opacity: 0.92; - transform: scale(1.05); + opacity: 0.86; } .dai-root .dai-send:active:not(:disabled) { - transform: scale(0.95); + transform: scale(0.94); } .dai-root .dai-send:focus-visible { outline: 2px solid var(--dai-accent); @@ -846,7 +839,6 @@ body.dai-docked .dai-page { .dai-root .dai-send:disabled { background: var(--dai-surface-hover); color: var(--dai-text-muted); - box-shadow: none; cursor: default; } .dai-root .dai-composer textarea { diff --git a/src/components/assistant/DoormileAI/AIComposer.jsx b/src/components/assistant/DoormileAI/AIComposer.jsx index b95ae38..31f91bf 100644 --- a/src/components/assistant/DoormileAI/AIComposer.jsx +++ b/src/components/assistant/DoormileAI/AIComposer.jsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { ArrowUp } from 'lucide-react'; +import { LuArrowUp } from 'react-icons/lu'; -import { HStack } from './shim'; -import { VStack } from './shim'; -import { Text } from './shim'; -import { ChatDictationButton, useChatDictation } from './shim'; +import { HStack } from '@astryxdesign/core/HStack'; +import { VStack } from '@astryxdesign/core/VStack'; +import { Text } from '@astryxdesign/core/Text'; +import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat'; // ==============================|| Doormile AI — composer ||============================== // // @@ -136,7 +136,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => { disabled={!canSend} aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'} > -