From 7544a07066dde98314fac62084769f13cfcfee11 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 23 Sep 2026 11:58:24 +0530 Subject: [PATCH] updates on the issue and the another thing updates on the ai optimization --- .../assistant/DoormileAI/AIAddressStep.jsx | 88 +++++++++++++ .../assistant/DoormileAI/AIMessage.jsx | 14 +++ .../assistant/DoormileAI/AIPanel.jsx | 6 +- src/components/assistant/intents.js | 24 ---- src/components/ds/DateRangeFields.jsx | 99 +++++++++++++++ src/lib/assistant/flowEngine.js | 9 +- src/lib/assistant/intents.js | 24 ++++ src/lib/assistant/orderFlow.js | 66 +++++++--- src/pages/doormile/home/Home.jsx | 42 ++++++- src/pages/doormile/orders/CreateOrder.jsx | 47 ++++++- src/pages/doormile/reports/OrdersDetails.jsx | 21 +--- src/pages/doormile/reports/OrdersSummary.jsx | 21 +--- src/pages/doormile/reports/Profitability.jsx | 4 + src/pages/doormile/reports/RidersSummary.jsx | 21 +--- tests/assistant/orderFlowAddress.test.js | 116 +++++++++++++++++ tests/assistant/pageSuggestions.test.js | 5 +- tests/ds/dateRangeFields.test.jsx | 119 ++++++++++++++++++ 17 files changed, 622 insertions(+), 104 deletions(-) create mode 100644 src/components/assistant/DoormileAI/AIAddressStep.jsx create mode 100644 src/components/ds/DateRangeFields.jsx create mode 100644 tests/assistant/orderFlowAddress.test.js create mode 100644 tests/ds/dateRangeFields.test.jsx diff --git a/src/components/assistant/DoormileAI/AIAddressStep.jsx b/src/components/assistant/DoormileAI/AIAddressStep.jsx new file mode 100644 index 0000000..d6d4c63 --- /dev/null +++ b/src/components/assistant/DoormileAI/AIAddressStep.jsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; + +import AddressAutocomplete from '@/components/doormile/AddressAutocomplete'; + +// ==============================|| Doormile AI — the address turn ||============================== // +// +// The address step, as a real search field rather than a line typed into the +// chat box. +// +// It used to be an ordinary `text` step: the assistant asked for "the full +// address" and the operator typed one into the composer, which was then +// geocoded blind. Two things were wrong with that. They got no suggestions, so +// an area or street had to be remembered and spelled the way the geocoder +// wanted; and when it missed, all that came back was "I couldn't find that +// address", with no sight of what it HAD found. +// +// This is the same `AddressAutocomplete` the Create Order page uses, so the +// two surfaces resolve an address the same way and cannot drift. +// +// Picking from the list also hands the flow the coordinates the operator +// actually chose, rather than whatever a second geocode of the same words +// returns — see the `place` branch in orderFlow's resolve. That matters: the +// dispatch optimiser routes on coordinates, and re-geocoding a label can +// legitimately return a different place. +// +// Typing free text and pressing the composer still works. This is an extra way +// in, not a replacement for one. +const AIAddressStep = ({ step, isBusy, onChoose, onCancel }) => { + const [text, setText] = useState(''); + const [picked, setPicked] = useState(null); + + return ( +
+
{step.ask}
+ + { + setText(v); + // Editing after a pick invalidates it. Those coordinates belonged to + // the old selection and must not ride along with new words — that is + // how a booking ends up located somewhere nobody chose. + setPicked(null); + }} + onPlaceSelected={(place) => { + setPicked(place); + setText(place?.formatted_address || place?.name || ''); + }} + /> + + {picked && ( +
+ Location set — {picked.city || picked.suburb || 'coordinates captured'} +
+ )} + +
+ + +
+
+ ); +}; + +AIAddressStep.propTypes = { + step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string }).isRequired, + isBusy: PropTypes.bool, + onChoose: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +export default AIAddressStep; diff --git a/src/components/assistant/DoormileAI/AIMessage.jsx b/src/components/assistant/DoormileAI/AIMessage.jsx index cde00f9..a6b34d4 100644 --- a/src/components/assistant/DoormileAI/AIMessage.jsx +++ b/src/components/assistant/DoormileAI/AIMessage.jsx @@ -12,6 +12,7 @@ import { IconButton } from '@/components/ds'; import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts'; import AIFlowStep from './AIFlowStep'; +import AIAddressStep from './AIAddressStep'; import AIRowsStep from './AIRowsStep'; import { failedRowsCsv, downloadCsv } from '@/lib/assistant/bulkFile'; @@ -127,6 +128,19 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho /> )} + {/* The address turn. Without this branch the step passed the gate in + AIPanel and nothing drew it — the question appeared with an empty + space beneath it, which is worse than the free-text version it + replaced. */} + {message.flowStep?.type === 'address' && !message.flowStep.__answered && ( + onChooseStep(message, value, option)} + onCancel={() => onCancelAction(message)} + /> + )} + {message.flowStep?.type === 'select' && ( { text: next.ask || step.ask, // A `select` or `rows` step carries the step definition so AIMessage // can render its control; a `text` step is answered in the composer. - flowStep: step.type === 'select' || step.type === 'rows' ? step : undefined, + // 'address' joins the list so the sidebar gets the same search + // field as Home. A step type absent here falls through to the plain + // composer, which is how the address step ended up asking for a full + // address as free text. + flowStep: ['select', 'rows', 'address'].includes(step.type) ? step : undefined, flowDraft: next.flow.draft }); return; diff --git a/src/components/assistant/intents.js b/src/components/assistant/intents.js index 85a0672..67867f5 100644 --- a/src/components/assistant/intents.js +++ b/src/components/assistant/intents.js @@ -2090,30 +2090,6 @@ const INTENTS = [ const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i])); -// Which intent the ordered catalog reaches for a question, WITHOUT running it. -// -// Exported for the page-suggestion test. The catalog is ordered and the first -// match wins, so "something matches this chip" and "the right thing matches -// this chip" are different questions — ROADMAP.md B1/B2 are two shipped cases -// where they had different answers. -// -// Covers the deterministic pass only. answerQuestion tries semantic routing -// and the multi-part splitter ahead of this, both of which are additive: they -// can route a question the catalog would miss, never away from one it hits. -export const resolveIntentId = (text) => { - const normalized = correctTypos(text); - for (const intent of INTENTS) { - let params = null; - try { - params = intent.match(normalized); - } catch { - params = null; - } - if (params) return intent.id; - } - return null; -}; - export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label); // Clean, directly-askable example phrasings — for "recommended question" diff --git a/src/components/ds/DateRangeFields.jsx b/src/components/ds/DateRangeFields.jsx new file mode 100644 index 0000000..996ab41 --- /dev/null +++ b/src/components/ds/DateRangeFields.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; + +import { Input } from '@/components/ui/input'; + +/** + * A from/to date pair for the report pages. + * + * It exists because the same two `` fields were copied into + * OrdersDetails, OrdersSummary and RidersSummary with no bounds on either, and + * that let through two ranges that can only ever return nothing: + * + * • INVERTED — from 30 Sep, to 1 Sep. Both inputs reported checkValidity() + * true, no message appeared anywhere, and the table showed zero rows. An + * operator reads that as "no orders in this period" when the filter is + * impossible. Same damage as a silently truncated count: an empty result + * presented as a finding. + * + * • FUTURE — January 2027. These reports filter on when an order was + * CREATED, so a future range is empty by definition. + * + * Bounds are enforced twice, because the two paths fail differently. `min` and + * `max` stop the browser's own picker offering an invalid day — that covers + * clicking, which is how it is actually used. The clamp in `commit` covers + * typing and pasting, which ignore those attributes entirely. + * + * When an edit would invert the range, the OTHER end moves to meet it rather + * than the edit being rejected. Picking a `from` after the current `to` means + * you have moved on to a later period; snapping `to` along with it is what you + * meant, and a rejected keystroke with no explanation is not. + */ +export function DateRangeFields({ value, onChange, max, label = 'to', className = '', size }) { + const today = dayjs().format('YYYY-MM-DD'); + const ceiling = max === null ? undefined : max || today; + const from = value?.from || ''; + const to = value?.to || ''; + + const clampToCeiling = (d) => (ceiling && d && d > ceiling ? ceiling : d); + + const commit = (which, raw) => { + // An empty value is a cleared field, not an invalid one — pass it through + // and let the consumer decide what an open-ended range means. + if (!raw) { + onChange({ ...value, [which]: raw }); + return; + } + const next = clampToCeiling(raw); + if (which === 'from') { + onChange({ from: next, to: to && next > to ? next : to }); + } else { + onChange({ from: from && next < from ? next : from, to: next }); + } + }; + + return ( +
+ commit('from', e.target.value)} + aria-label="From date" + className="w-36" + /> + {label} + commit('to', e.target.value)} + aria-label="To date" + className="w-36" + /> +
+ ); +} + +DateRangeFields.propTypes = { + /** `{ from, to }` as YYYY-MM-DD strings. */ + value: PropTypes.shape({ from: PropTypes.string, to: PropTypes.string }).isRequired, + /** Called with the whole next `{ from, to }`, already clamped. */ + onChange: PropTypes.func.isRequired, + /** + * Latest selectable day. Defaults to today, because these reports filter on + * creation date. Pass `null` for a picker that may legitimately look ahead. + */ + max: PropTypes.string, + label: PropTypes.string, + className: PropTypes.string, + size: PropTypes.string +}; + +export default DateRangeFields; diff --git a/src/lib/assistant/flowEngine.js b/src/lib/assistant/flowEngine.js index 09705b8..aabe912 100644 --- a/src/lib/assistant/flowEngine.js +++ b/src/lib/assistant/flowEngine.js @@ -74,7 +74,14 @@ export const answerFlowStep = async (steps, flow, raw, option) => { let value = raw; if (s.resolve) { - const resolved = await s.resolve(raw); + // `option` as well as `raw`, so a step whose UI already resolved the + // answer can say so. The address step picks a place from a suggestion + // list that has already returned coordinates — re-geocoding the label it + // just handed us is a round trip that can also come back with a DIFFERENT + // place than the one the operator chose. + // + // Every existing resolve ignores the second argument, so this is additive. + const resolved = await s.resolve(raw, option); if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true }; value = resolved.value; } diff --git a/src/lib/assistant/intents.js b/src/lib/assistant/intents.js index 67867f5..85a0672 100644 --- a/src/lib/assistant/intents.js +++ b/src/lib/assistant/intents.js @@ -2090,6 +2090,30 @@ const INTENTS = [ const INTENTS_BY_ID = Object.fromEntries(INTENTS.map((i) => [i.id, i])); +// Which intent the ordered catalog reaches for a question, WITHOUT running it. +// +// Exported for the page-suggestion test. The catalog is ordered and the first +// match wins, so "something matches this chip" and "the right thing matches +// this chip" are different questions — ROADMAP.md B1/B2 are two shipped cases +// where they had different answers. +// +// Covers the deterministic pass only. answerQuestion tries semantic routing +// and the multi-part splitter ahead of this, both of which are additive: they +// can route a question the catalog would miss, never away from one it hits. +export const resolveIntentId = (text) => { + const normalized = correctTypos(text); + for (const intent of INTENTS) { + let params = null; + try { + params = intent.match(normalized); + } catch { + params = null; + } + if (params) return intent.id; + } + return null; +}; + export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label); // Clean, directly-askable example phrasings — for "recommended question" diff --git a/src/lib/assistant/orderFlow.js b/src/lib/assistant/orderFlow.js index 8c19785..606ee7b 100644 --- a/src/lib/assistant/orderFlow.js +++ b/src/lib/assistant/orderFlow.js @@ -127,26 +127,60 @@ export const ORDER_STEPS = [ }, { id: 'deliveryaddress', - type: 'text', - ask: 'Where is it being delivered? Give the full address.', + // `address` renders a real search field with live suggestions instead of + // leaving the operator to type a full address into the chat box and hope + // the geocoder finds it. Typing still works — the resolve below falls back + // to geocoding free text — so this step degrades to its old behaviour + // wherever the field is not rendered. + type: 'address', + ask: 'Where is it being delivered? Start typing the area, street or pincode.', // Geocoded on the way in: the dispatch optimiser routes on coordinates, so // an address that can't be located is refused here rather than becoming a // booking nothing can dispatch. - resolve: async (t) => { - const place = await geocodeAddress(String(t).trim()).catch(() => null); + resolve: async (t, option) => { + // A place picked from the suggestion list is already resolved, with the + // coordinates the operator actually chose. Geocoding its label again + // can return a different place entirely. + const place = option?.place || (await geocodeAddress(String(t).trim()).catch(() => null)); if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' }; - const parts = { deliveryaddress: place.formatted_address || String(t).trim() }; - (place.address_components || []).forEach((c) => { - if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name; - if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name; - }); - return { - value: { - ...parts, - deliverylatitude: place.geometry?.location?.lat?.(), - deliverylongitude: place.geometry?.location?.lng?.() - } - }; + + // geocodingService.standardizePlace returns a FLAT place — formatted_address, + // city, postcode, latitude, longitude. This used to read Google's raw + // shape instead (`address_components`, `geometry.location.lat()`), and + // neither exists on what geocodeAddress actually returns: the forEach + // never ran and both coordinates came back undefined. Every order created + // through this flow was therefore missing its delivery coordinates — the + // exact thing the comment above says the optimiser routes on — and the + // pincode step below fired every time because nothing ever set it. + // + // The Google shape is still read as a fallback so a caller that hands in + // a raw Places result keeps working. + const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined); + let lat = num(place.latitude); + let lng = num(place.longitude); + let city = place.city || ''; + let pincode = place.postcode || ''; + + if (lat === undefined && place.geometry?.location) { + const loc = place.geometry.location; + lat = num(typeof loc.lat === 'function' ? loc.lat() : loc.lat); + lng = num(typeof loc.lng === 'function' ? loc.lng() : loc.lng); + } + if (!city || !pincode) { + (place.address_components || []).forEach((c) => { + if (!city && (c.types || []).includes('locality')) city = c.long_name; + if (!pincode && (c.types || []).includes('postal_code')) pincode = c.long_name; + }); + } + + const value = { deliveryaddress: place.formatted_address || place.name || String(t).trim() }; + if (city) value.deliverycity = city; + if (pincode) value.deliverypincode = pincode; + if (lat !== undefined && lng !== undefined) { + value.deliverylatitude = lat; + value.deliverylongitude = lng; + } + return { value }; }, apply: (d, v) => ({ ...d, ...v }) }, diff --git a/src/pages/doormile/home/Home.jsx b/src/pages/doormile/home/Home.jsx index 9c469ae..59f800b 100644 --- a/src/pages/doormile/home/Home.jsx +++ b/src/pages/doormile/home/Home.jsx @@ -32,6 +32,7 @@ import { OpenToast } from '@/components/third-party/OpenToast'; import { answerQuestion } from '@/lib/assistant/intents'; import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions'; import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions'; +import AIAddressStep from '@/components/assistant/DoormileAI/AIAddressStep'; import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow'; import { answerAssignStep } from '@/lib/assistant/assignFlow'; import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions'; @@ -1371,6 +1372,17 @@ export default function Home() { /> )} + {/* Address step — a search field with suggestions, not a + full address typed into the chat box. */} + {turn.flowStep?.type === 'address' && !turn.flowStep.__answered && ( + chooseStep(turn, val, opt)} + onCancel={() => cancelFlowAction(turn)} + /> + )} + {/* Itemized Preview List (e.g. Orders to be created / repeated) */} {turn.itemsList && turn.itemsList.length > 0 && (
@@ -1996,10 +2008,30 @@ export default function Home() { - {/* All Agents */} - +
diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 6bcf5f1..37e349a 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -478,13 +478,56 @@ export default function CreateOrder() { // Select customer from modal const handlePickCustomer = (customer) => { + // GET /admin/customers already returns the whole address — doorno, address, + // suburb, city, state, postcode, landmark, latitude, longitude (see + // GetAdminCustomers in adminController.go). This used to take the name and + // the phone and drop the rest on the floor, so picking a saved contact + // filled two fields and left the operator to retype an address the system + // already had. + // + // The COORDINATES matter more than the text. handleSubmit validates drop + // coordinates, and the Order Summary's distance and total are computed + // from them — filling the address without them produces a form that looks + // complete, prices at the base rate, and then fails on submit. That is + // worse than filling nothing. + const digits = (v) => String(v ?? '').replace(/\D/g, ''); + const text = (v) => String(v ?? '').trim(); + + // Door number and landmark are separate columns but there is one address + // line on this form, so fold them in rather than lose them. + const street = [text(customer.doorno), text(customer.address)].filter(Boolean).join(', '); + const lat = customer.latitude; + const lng = customer.longitude; + const hasCoords = Number.isFinite(Number(lat)) && Number.isFinite(Number(lng)) && Number(lat) !== 0 && Number(lng) !== 0; + setDropCust((prev) => ({ ...prev, firstname: customer.name || prev.firstname, - contactno: String(customer.phone || prev.contactno || '').replace(/\D/g, '').slice(0, 10) + contactno: digits(customer.phone) ? digits(customer.phone).slice(0, 10) : prev.contactno, + // Every field falls back to what is already typed. A contact with a name + // and no address must never blank an address the operator has entered — + // silently erasing their work is a worse bug than not filling it. + address: street || prev.address, + landmark: text(customer.landmark) || prev.landmark, + suburb: text(customer.suburb) || prev.suburb, + city: text(customer.city) || prev.city, + postcode: text(customer.postcode) || prev.postcode, + latitude: hasCoords ? lat : prev.latitude, + longitude: hasCoords ? lng : prev.longitude })); setIsCustomerModalOpen(false); - OpenToast(`Selected customer: ${customer.name || customer.phone}`, 'info', 2000); + + // Say what actually happened. A contact with no address on file leaves the + // operator with a half-filled form, and a toast that says "Selected" either + // way lets them assume the address came across and submit without it. + const name = customer.name || customer.phone || 'Contact'; + if (street && hasCoords) { + OpenToast(`${name} — contact and address filled`, 'success', 2500); + } else if (street) { + OpenToast(`${name} — address filled, but no saved location. Pin it on the map for distance and pricing.`, 'warning', 4500); + } else { + OpenToast(`${name} — no address on file. Enter the delivery address.`, 'warning', 4000); + } }; // Submit Order diff --git a/src/pages/doormile/reports/OrdersDetails.jsx b/src/pages/doormile/reports/OrdersDetails.jsx index c7f6df2..556e8eb 100644 --- a/src/pages/doormile/reports/OrdersDetails.jsx +++ b/src/pages/doormile/reports/OrdersDetails.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import dayjs from 'dayjs'; import { FileSpreadsheet, ListOrdered } from 'lucide-react'; import { - Button, DataTable, EmptyState, Input, PageHeader, Select, SelectContent, SelectItem, + Button, DataTable, EmptyState, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, StatusBadge, } from '@/components/ds'; import { ListToolbar } from '@/components/doormile/ListToolbar'; @@ -10,6 +10,7 @@ import { useDeliveries, useRiderOptions, useTenants } from '@/lib/doormileHooks' import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; import OperationalReportCards from './OperationalReportCards'; +import { DateRangeFields } from '@/components/ds/DateRangeFields'; /** * Orders details — the row-level export behind the summary. @@ -181,23 +182,7 @@ export default function OrdersDetails() { } > -
- setRange((current) => ({ ...current, from: e.target.value }))} - aria-label="From date" - className="w-36" - /> - to - setRange((current) => ({ ...current, to: e.target.value }))} - aria-label="To date" - className="w-36" - /> -
+ setRange((current) => ({ ...current, from: e.target.value }))} - aria-label="From date" - className="w-36" - /> - to - setRange((current) => ({ ...current, to: e.target.value }))} - aria-label="To date" - className="w-36" - /> - + e.target.value && setDate(e.target.value)} aria-label="Report date" className="w-44" diff --git a/src/pages/doormile/reports/RidersSummary.jsx b/src/pages/doormile/reports/RidersSummary.jsx index 8201f9c..3841e98 100644 --- a/src/pages/doormile/reports/RidersSummary.jsx +++ b/src/pages/doormile/reports/RidersSummary.jsx @@ -2,12 +2,13 @@ import React, { useMemo, useState } from 'react'; import dayjs from 'dayjs'; import { Bike, CheckCircle2, FileSpreadsheet, IndianRupee, Package } from 'lucide-react'; import { - Button, DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Select, SelectContent, + Button, DataTable, EmptyState, Grid, KpiCard, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, } from '@/components/ds'; import { ListToolbar } from '@/components/doormile/ListToolbar'; import { useAppLocations, useRidersSummary } from '@/lib/doormileHooks'; import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; +import { DateRangeFields } from '@/components/ds/DateRangeFields'; /** * Riders summary — per-rider KPI counts for a date range. @@ -155,23 +156,7 @@ export default function RidersSummary() { } > -
- setRange((current) => ({ ...current, from: e.target.value }))} - aria-label="From date" - className="w-36" - /> - to - setRange((current) => ({ ...current, to: e.target.value }))} - aria-label="To date" - className="w-36" - /> -
+