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 && (
+
+ );
+};
+
+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 (
+
+ );
+}
+
+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() {
}
>
-