updates on the issue and the another thing updates on the ai optimization

This commit is contained in:
2026-09-23 11:58:24 +05:30
parent c6dfa3cb8f
commit 7544a07066
17 changed files with 622 additions and 104 deletions

View File

@@ -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 (
<div className="w-full border border-slate-200/90 rounded-2xl bg-white p-3.5 sm:p-4 mb-3.5 shadow-3xs space-y-3">
<div className="text-xs font-bold text-slate-800">{step.ask}</div>
<AddressAutocomplete
id={`ai-address-${step.id}`}
value={text}
disabled={isBusy}
onChange={(v) => {
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 && (
<div className="text-[11px] text-emerald-700 font-medium">
Location set — {picked.city || picked.suburb || 'coordinates captured'}
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={isBusy || !text.trim()}
onClick={() => onChoose(text.trim(), picked ? { place: picked } : undefined)}
className="px-4 py-2 bg-brand hover:bg-brand-dark text-white font-semibold text-xs rounded-xl shadow-xs active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer"
>
Continue
</button>
<button
type="button"
onClick={onCancel}
className="px-3 py-2 text-slate-500 hover:text-slate-800 text-xs font-medium cursor-pointer"
>
Cancel
</button>
</div>
</div>
);
};
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;

View File

@@ -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 && (
<AIAddressStep
step={message.flowStep}
isBusy={message.flowStep.__answered}
onChoose={(value, option) => onChooseStep(message, value, option)}
onCancel={() => onCancelAction(message)}
/>
)}
{message.flowStep?.type === 'select' && (
<AIFlowStep
step={message.flowStep}

View File

@@ -400,7 +400,11 @@ const AIPanel = ({ isOpen, onClose }) => {
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;

View File

@@ -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"

View File

@@ -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 `<Input type="date">` 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 (
<div className={`flex items-center gap-1.5 ${className}`.trim()}>
<Input
type="date"
size={size}
value={from}
// Bounded by the to-date as well as today, so the picker cannot offer
// a day that would invert the range.
max={to && (!ceiling || to < ceiling) ? to : ceiling}
onChange={(e) => commit('from', e.target.value)}
aria-label="From date"
className="w-36"
/>
<span className="text-xs text-ink-4">{label}</span>
<Input
type="date"
size={size}
value={to}
min={from || undefined}
max={ceiling}
onChange={(e) => commit('to', e.target.value)}
aria-label="To date"
className="w-36"
/>
</div>
);
}
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;

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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 })
},

View File

@@ -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 && (
<AIAddressStep
step={turn.flowStep}
isBusy={turn.flowStep.__answered}
onChoose={(val, opt) => chooseStep(turn, val, opt)}
onCancel={() => cancelFlowAction(turn)}
/>
)}
{/* Itemized Preview List (e.g. Orders to be created / repeated) */}
{turn.itemsList && turn.itemsList.length > 0 && (
<div className="w-full border border-slate-200/90 rounded-2xl bg-white p-3.5 sm:p-4 mb-3.5 shadow-3xs space-y-2">
@@ -1996,10 +2008,30 @@ export default function Home() {
</span>
</Link>
{/* All Agents */}
<button
type="button"
onClick={() => handleAsk('Summarize status across all 9 specialized agents')}
{/* All Agents.
This used to ask the assistant to "Summarize status across all
9 specialized agents". Nothing in the intent catalogue matches
the word "agents", so every click landed on the honest
"I could not answer that from live data" fallback — a control
that reliably fails, on the page where an operator forms their
impression of whether the AI works at all.
It could not have worked. Agent telemetry lives in AI_engine's
command centre (FastAPI, port 8600) and the console has no
base URL for it, no env var, and nothing deployed to point at;
the console itself holds no agent state to summarise. The one
thing NOT done here is synthesising a number from console
data — see the note on the fallback branch above, where a
previous version forged a sourceCalls entry claiming
"46 milers scanned" for a call that was never made.
So it now opens the page that really is about agents. When the
audit tables in docs/agent-platform-plan.md §11 land, agent
status becomes an ordinary query and this can become a
question again. */}
<Link
to="/doormile/settings?tab=skills"
className="flex flex-col items-center group focus:outline-none w-18 text-center cursor-pointer"
>
<div className="relative w-12 h-12 rounded-2xl bg-indigo-50 border border-indigo-200/90 flex items-center justify-center text-indigo-600 group-hover:scale-105 group-hover:bg-indigo-100 group-hover:shadow-sm transition-all duration-200">
@@ -2011,7 +2043,7 @@ export default function Home() {
<span className="text-[11px] sm:text-xs font-medium text-slate-700 group-hover:text-slate-900 mt-2 text-center truncate w-full transition-colors">
All Agents
</span>
</button>
</Link>
</div>
</div>

View File

@@ -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

View File

@@ -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() {
</Button>
}
>
<div className="flex items-center gap-1.5">
<Input
type="date"
value={range.from}
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
aria-label="From date"
className="w-36"
/>
<span className="text-xs text-ink-4">to</span>
<Input
type="date"
value={range.to}
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
aria-label="To date"
className="w-36"
/>
</div>
<DateRangeFields value={range} onChange={setRange} />
<Select value={tenantId} onValueChange={setTenantId}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="All clients" />

View File

@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { BarChart3, CheckCircle2, FileSpreadsheet, IndianRupee, Package, Timer } 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';
@@ -10,6 +10,7 @@ import {
useAppLocations, useReportLocationSummary, useReportSummary, useTenantLocations, useTenants,
} from '@/lib/doormileHooks';
import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
import { DateRangeFields } from '@/components/ds/DateRangeFields';
/**
* Orders summary — the same counts the Orders page shows, grouped.
@@ -212,23 +213,7 @@ export default function OrdersSummary() {
</Button>
}
>
<div className="flex items-center gap-1.5">
<Input
type="date"
value={range.from}
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
aria-label="From date"
className="w-36"
/>
<span className="text-xs text-ink-4">to</span>
<Input
type="date"
value={range.to}
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
aria-label="To date"
className="w-36"
/>
</div>
<DateRangeFields value={range} onChange={setRange} />
<Select value={zone} onValueChange={setZone}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="All zones" />

View File

@@ -166,6 +166,10 @@ export default function Profitability({ embedded = false, date: controlledDate,
<Input
type="date"
value={date}
// Profitability reports on a day that has happened. Without a
// ceiling the picker happily offers next year, which returns an
// empty report that reads as "no profit" rather than "no data".
max={dayjs().format('YYYY-MM-DD')}
onChange={(e) => e.target.value && setDate(e.target.value)}
aria-label="Report date"
className="w-44"

View File

@@ -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() {
</Button>
}
>
<div className="flex items-center gap-1.5">
<Input
type="date"
value={range.from}
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
aria-label="From date"
className="w-36"
/>
<span className="text-xs text-ink-4">to</span>
<Input
type="date"
value={range.to}
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
aria-label="To date"
className="w-36"
/>
</div>
<DateRangeFields value={range} onChange={setRange} />
<Select value={zone} onValueChange={setZone}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder="All zones" />

View File

@@ -0,0 +1,116 @@
// orderFlow imports geocodeAddress from a component that pulls in
// @astryxdesign ESM, which jest does not transform. Only the function matters
// here, and every assertion below supplies its own place, so the fallback
// geocode is stubbed to "found nothing" — which is also the case the last
// test covers.
jest.mock('components/nearle_components/AddressAutocomplete', () => ({
geocodeAddress: jest.fn().mockResolvedValue(null)
}));
import { ORDER_STEPS } from '@/lib/assistant/orderFlow';
/**
* The address step of the conversational order flow.
*
* It was reading Google's raw Places shape — `address_components` and
* `geometry.location.lat()` — from a value that never has either.
* `geocodeAddress` returns whatever `getAddressSuggestions` produced, and that
* goes through `standardizePlace`, which emits a FLAT place: formatted_address,
* city, postcode, latitude, longitude.
*
* So the forEach never ran and both coordinates came back undefined. Every
* order created through the assistant was missing its delivery coordinates —
* the thing the step's own comment says the dispatch optimiser routes on — and
* the separate "what's the delivery pincode?" step fired every time because
* nothing had ever set it.
*/
const addressStep = ORDER_STEPS.find((s) => s.id === 'deliveryaddress');
// What geocodingService.standardizePlace actually returns.
const flatPlace = {
formatted_address: '18 Race Course Road, Coimbatore, Tamil Nadu 641018',
name: '18 Race Course Road',
suburb: 'Race Course',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641018',
latitude: 11.0038,
longitude: 76.9645
};
describe('the address step', () => {
it('is an address step, so the UI can render a search field for it', () => {
// A type the renderer does not know falls through to the plain chat
// composer — which is how this ended up asking for a full address as free
// text with no suggestions.
expect(addressStep.type).toBe('address');
});
it('reads coordinates off the FLAT place shape', async () => {
const { value } = await addressStep.resolve('race course road', { place: flatPlace });
expect(value.deliverylatitude).toBeCloseTo(11.0038);
expect(value.deliverylongitude).toBeCloseTo(76.9645);
});
it('reads city and pincode off the flat shape too', async () => {
const { value } = await addressStep.resolve('race course road', { place: flatPlace });
expect(value.deliverycity).toBe('Coimbatore');
expect(value.deliverypincode).toBe('641018');
});
it('skips the pincode question once the place supplied one', async () => {
// `when: (d) => !d.deliverypincode` — the step only exists for addresses
// that could not supply one. It used to fire every single time.
const pincodeStep = ORDER_STEPS.find((s) => s.id === 'deliverypincode');
const { value } = await addressStep.resolve('race course road', { place: flatPlace });
const draft = addressStep.apply({}, value);
expect(pincodeStep.when(draft)).toBe(false);
});
it('still understands a raw Google Places result', async () => {
// Kept as a fallback so a caller handing in an unstandardised result does
// not silently lose its coordinates the way this step used to.
const googlePlace = {
formatted_address: '5 Gandhipuram, Coimbatore',
geometry: { location: { lat: () => 11.0168, lng: () => 76.9558 } },
address_components: [
{ long_name: 'Coimbatore', types: ['locality'] },
{ long_name: '641012', types: ['postal_code'] }
]
};
const { value } = await addressStep.resolve('gandhipuram', { place: googlePlace });
expect(value.deliverylatitude).toBeCloseTo(11.0168);
expect(value.deliverycity).toBe('Coimbatore');
expect(value.deliverypincode).toBe('641012');
});
it('accepts plain lat/lng numbers, not only getter functions', async () => {
const { value } = await addressStep.resolve('somewhere', {
place: { formatted_address: 'X', geometry: { location: { lat: 12.5, lng: 77.5 } } }
});
expect(value.deliverylatitude).toBe(12.5);
expect(value.deliverylongitude).toBe(77.5);
});
it('never emits a coordinate pair with only one half', async () => {
// A booking with a latitude and no longitude is worse than one with
// neither: it looks located and routes to the wrong place.
const { value } = await addressStep.resolve('nowhere', {
place: { formatted_address: 'No coords', city: 'Coimbatore' }
});
expect(value.deliverylatitude).toBeUndefined();
expect(value.deliverylongitude).toBeUndefined();
// and it still keeps what it DID learn
expect(value.deliverycity).toBe('Coimbatore');
expect(value.deliveryaddress).toBe('No coords');
});
it('refuses rather than inventing when nothing resolves', async () => {
const result = await addressStep.resolve('qqqq', { place: null });
// With no option place it falls back to geocoding, which in a test
// environment resolves to nothing — the step must report that, not guess.
expect(result.error || result.value).toBeTruthy();
if (result.error) expect(result.error).toMatch(/couldn.t find that address/i);
});
});

View File

@@ -1,5 +1,8 @@
import { getPageContext, CHIP_LABELS } from '@/components/assistant/DoormileAI/pageContext';
import { resolveIntentId } from '@/components/assistant/intents';
// The LIVE catalogue. src/components/assistant/*.js are dead duplicates with
// no importers — AIPanel and Home both import from @/lib/assistant. Testing
// the copy that never runs is how a green suite hides a broken page.
import { resolveIntentId } from '@/lib/assistant/intents';
/**
* Every suggestion a page offers must resolve — and must resolve to the

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import dayjs from 'dayjs';
import { DateRangeFields } from '@/components/ds/DateRangeFields';
/**
* The two ranges that can only ever return nothing.
*
* Both were accepted silently by the report pages before this component
* existed: an inverted range (from 30 Sep, to 1 Sep) and a future one. Neither
* input reported invalid, no message appeared, and the table showed zero rows —
* which an operator reads as "no orders in this period" rather than "this
* filter is impossible". An empty result presented as a finding is the same
* class of defect as a silently truncated count.
*
* `min`/`max` cover the picker, which is how it is actually used. The clamp in
* onChange covers typing and pasting, which ignore those attributes — so both
* are asserted here.
*/
const today = dayjs().format('YYYY-MM-DD');
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
const lastWeek = dayjs().subtract(7, 'day').format('YYYY-MM-DD');
const nextYear = dayjs().add(1, 'year').format('YYYY-MM-DD');
const setup = (value) => {
const onChange = jest.fn();
render(<DateRangeFields value={value} onChange={onChange} />);
return {
onChange,
from: screen.getByLabelText('From date'),
to: screen.getByLabelText('To date')
};
};
describe('bounds the picker itself', () => {
it('neither field offers a day after today', () => {
const { from, to } = setup({ from: lastWeek, to: yesterday });
expect(to).toHaveAttribute('max', today);
// from is capped by the to-date, which is tighter than today here
expect(from).toHaveAttribute('max', yesterday);
});
it('the to-field cannot go earlier than the from-field', () => {
const { to } = setup({ from: lastWeek, to: today });
expect(to).toHaveAttribute('min', lastWeek);
});
it('with no to-date yet, from is capped at today', () => {
const { from } = setup({ from: '', to: '' });
expect(from).toHaveAttribute('max', today);
});
});
describe('clamps what the picker cannot — typing and pasting', () => {
it('a future from-date is pulled back to today', () => {
const { from, onChange } = setup({ from: today, to: today });
fireEvent.change(from, { target: { value: nextYear } });
expect(onChange).toHaveBeenCalledWith({ from: today, to: today });
});
it('a future to-date is pulled back to today', () => {
const { to, onChange } = setup({ from: lastWeek, to: today });
fireEvent.change(to, { target: { value: nextYear } });
expect(onChange).toHaveBeenCalledWith({ from: lastWeek, to: today });
});
it('moving from PAST to drags to along, rather than inverting', () => {
// The edit is honoured — you have moved to a later period — and the other
// end follows. Rejecting the keystroke with no explanation is worse.
const { from, onChange } = setup({ from: lastWeek, to: yesterday });
fireEvent.change(from, { target: { value: today } });
expect(onChange).toHaveBeenCalledWith({ from: today, to: today });
});
it('moving to BEFORE from drags from back, rather than inverting', () => {
const { to, onChange } = setup({ from: yesterday, to: today });
fireEvent.change(to, { target: { value: lastWeek } });
expect(onChange).toHaveBeenCalledWith({ from: lastWeek, to: lastWeek });
});
it('never emits a range where from is after to', () => {
// The property, stated once. Every emission from any edit must hold it.
const cases = [
[{ from: lastWeek, to: yesterday }, 'From date', today],
[{ from: yesterday, to: today }, 'To date', lastWeek],
[{ from: today, to: today }, 'From date', nextYear],
[{ from: lastWeek, to: today }, 'To date', nextYear]
];
cases.forEach(([value, label, typed]) => {
const onChange = jest.fn();
const { unmount } = render(<DateRangeFields value={value} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(label), { target: { value: typed } });
const next = onChange.mock.calls[0][0];
expect(next.from <= next.to).toBe(true);
expect(next.to <= today).toBe(true);
unmount();
});
});
});
describe('a cleared field is not an invalid one', () => {
it('passes an empty value through untouched', () => {
const { to, onChange } = setup({ from: lastWeek, to: today });
fireEvent.change(to, { target: { value: '' } });
expect(onChange).toHaveBeenCalledWith({ from: lastWeek, to: '' });
});
});
describe('the ceiling is overridable', () => {
it('max={null} allows a future date, for a picker that should look ahead', () => {
const onChange = jest.fn();
render(<DateRangeFields value={{ from: today, to: today }} onChange={onChange} max={null} />);
const from = screen.getByLabelText('From date');
fireEvent.change(from, { target: { value: nextYear } });
expect(onChange).toHaveBeenCalledWith({ from: nextYear, to: nextYear });
});
});