updates on the issue and the another thing updates on the ai optimization
This commit is contained in:
88
src/components/assistant/DoormileAI/AIAddressStep.jsx
Normal file
88
src/components/assistant/DoormileAI/AIAddressStep.jsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
99
src/components/ds/DateRangeFields.jsx
Normal file
99
src/components/ds/DateRangeFields.jsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
116
tests/assistant/orderFlowAddress.test.js
Normal file
116
tests/assistant/orderFlowAddress.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
119
tests/ds/dateRangeFields.test.jsx
Normal file
119
tests/ds/dateRangeFields.test.jsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user