updates on the changes and fix on the all the pages
This commit is contained in:
@@ -37,7 +37,8 @@ An in-console Q&A assistant that answers operator questions about live data —
|
||||
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
|
||||
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
|
||||
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
|
||||
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
|
||||
- **Each page offers ONLY its own suggestions.** `getPageContext` returns the route's context list and nothing else — no global set is appended. Asked for directly: on Orders the strip should be Orders questions, and likewise everywhere. This REVERSES the previous rule ("every page offers every suggestion"), which had already been walked back once from all twenty-one questions to a thirteen-question global set because Orders was offering "How many tenants do we have?" above the composer. Note it was barely observable before the change: every context declares at least four suggestions and the panel slices to four, so the globals mostly sat just past the cut — the behaviour was an accident of counting, not a rule. It is structural now. Dropping a question from a page's list does not remove it from the assistant: it stays typeable, stays a follow-up, and still leads on its own page.
|
||||
- **A route missing from `ROUTES` falls back to `DEFAULT_CONTEXT`**, which is the generic operations set — and that is how a page ends up offering hub questions beside a table of bookings. `/doormile/bookings` did exactly that until it was given its own context. `/doormile/app-users` and `/doormile/competitive-intel` still fall back deliberately: the intent catalog has no questions about app users or competitor branches, and inventing chips that resolve to nothing would break the rule two lines down. Give a page its own context when real intents exist for it, not before.
|
||||
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
|
||||
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
|
||||
// Sequential on purpose. These are writes against real dispatch records, and
|
||||
// firing a burst of them concurrently makes a partial failure much harder to
|
||||
// report accurately — which order did not land, and to whom.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
|
||||
assigned += 1;
|
||||
assignedRiders.add(String(t.mileruserid));
|
||||
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
|
||||
// drops back should feel one buzz, not ten.
|
||||
let notified = 0;
|
||||
if (lookup) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
for (const userid of assignedRiders) {
|
||||
const rider = lookup.byUserId.get(userid);
|
||||
if (rider?.milerprofileid) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified += 1;
|
||||
} catch {
|
||||
|
||||
@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
|
||||
|
||||
if (hasCoords(row)) {
|
||||
located.push(row);
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
const key = cacheKey(row);
|
||||
if (cache?.has(key)) {
|
||||
located.push({ ...row, ...cache.get(key) });
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
|
||||
const found = {
|
||||
deliverylatitude: place?.geometry?.location?.lat?.(),
|
||||
@@ -147,7 +147,7 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
|
||||
|
||||
// Only wait after a real request. A cache hit or a sheet coordinate costs
|
||||
// nothing, which is what makes a re-run fast.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
|
||||
@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
|
||||
|
||||
if (String(row.finalprice ?? '') !== '') {
|
||||
out.push(row);
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
if (!match) {
|
||||
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
|
||||
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
|
||||
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
|
||||
|
||||
if (km == null) {
|
||||
out.push({ ...row, priceError: 'could not measure the distance' });
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
||||
@@ -209,7 +209,7 @@ export const executeCreateBulk = async (rows, shared) => {
|
||||
const batch = batches[b];
|
||||
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const res = await createExpressBookingBulk(batch);
|
||||
// Three shapes, most-nested first. The live endpoint returns
|
||||
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
|
||||
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
|
||||
status: 'error',
|
||||
errorMessage: res.message || 'Rejected'
|
||||
});
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export const advance = (flow) => {
|
||||
const already = s.field === 'name' ? draft.firstname : draft[s.field];
|
||||
if (already) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step }, ask: s.ask, done: false };
|
||||
|
||||
@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
|
||||
const s = steps[step];
|
||||
if (!applicable(s, draft) || draft[s.id] !== undefined) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.auto) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const auto = await s.auto(draft);
|
||||
if (auto?.patch) draft = { ...draft, ...auto.patch };
|
||||
if (auto?.value !== undefined) {
|
||||
draft = s.apply(draft, auto.value);
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
|
||||
|
||||
@@ -1,613 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
assignMilerToBooking, createExpressBooking, createExpressBookingBulk, createTenantCustomer,
|
||||
getAdminCustomers, getAdminPricing, getalltenants, getallriders, notifyMiler,
|
||||
} from '@/api/doormile';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
|
||||
import { fetchBookingsForDay, scanBookings } from './scan';
|
||||
import { bestNameMatch, bookingCharge, dayFromWords, formatRupees, isCancelled } from './vocab';
|
||||
|
||||
/**
|
||||
* The assistant's five writes: a customer, a single order, a batch, a rider
|
||||
* assignment, and a repeated day.
|
||||
*
|
||||
* **All five are conversations**, one question per turn — not forms. That is
|
||||
* explicit product direction, and there is no create-form component here.
|
||||
*
|
||||
* **The write gate is non-negotiable.** A flow gathers, then shows exactly what
|
||||
* will be sent, and the mutation fires only when the operator presses the
|
||||
* button. `execute` is the only mutating function in this module and nothing
|
||||
* calls it from a `match`. No intent can trigger a write.
|
||||
*
|
||||
* The panel intercepts a reply *before* the router sees it whenever a flow is
|
||||
* open. That is load-bearing rather than a tidy-up: the router matches text, and
|
||||
* a bare answer like `9876543210` matches no intent, so without the intercept
|
||||
* every reply would be lost to "I can't answer that one yet".
|
||||
*/
|
||||
|
||||
/** Customer creation writes to `/admin/tenantcustomers`.
|
||||
*
|
||||
* Settled by evidence: `POST /admin/customers` answers 405 Method Not Allowed —
|
||||
* the route exists and POST is not among its methods. That resource grows a
|
||||
* customer as a side effect of a booking, which is also why its records carry
|
||||
* no address. The Customers page reads tenant customers for the same reason, so
|
||||
* a customer created here appears there immediately.
|
||||
*
|
||||
* Creating one *via a booking* was rejected outright: "add a customer" must
|
||||
* never silently dispatch a delivery.
|
||||
*/
|
||||
const CUSTOMER_STEPS = [
|
||||
{ key: 'firstname', question: 'What is the customer’s first name?', required: true },
|
||||
{ key: 'lastname', question: 'And their last name? (say "skip" if you don’t have it)' },
|
||||
{
|
||||
key: 'phone',
|
||||
question: 'Their 10-digit mobile number?',
|
||||
required: true,
|
||||
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
|
||||
},
|
||||
{ key: 'email', question: 'An email address? (say "skip" if there isn’t one)' },
|
||||
];
|
||||
|
||||
const ORDER_STEPS = [
|
||||
{ key: 'tenant', question: 'Which client is this order for?', required: true },
|
||||
{ key: 'pickupaddress', question: 'Where is it collected from?', required: true },
|
||||
{ key: 'pickupcity', question: 'Which city is the pickup in?', required: true },
|
||||
{ key: 'pickuppincode', question: 'And the pickup pincode?', required: true },
|
||||
{ key: 'customer_name', question: 'Who is receiving it?', required: true },
|
||||
{
|
||||
key: 'customer_phone',
|
||||
question: 'Their 10-digit mobile number?',
|
||||
required: true,
|
||||
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
|
||||
},
|
||||
{ key: 'deliveryaddress', question: 'What is the delivery address?', required: true },
|
||||
{ key: 'deliverycity', question: 'Which city is the delivery in?', required: true },
|
||||
{ key: 'deliverypincode', question: 'And the delivery pincode?', required: true },
|
||||
{
|
||||
key: 'finalprice',
|
||||
question: 'What should this be charged at? (a number, or "skip" to send 0)',
|
||||
validate: (value) =>
|
||||
value === '' || !Number.isNaN(Number(value)) ? null : 'That is not a number — try again.',
|
||||
},
|
||||
{ key: 'notes', question: 'Anything the rider should know? (or "skip")' },
|
||||
];
|
||||
|
||||
const BULK_STEPS = [
|
||||
{ key: 'tenant', question: 'Which client are these orders for?', required: true },
|
||||
{ key: 'pickupaddress', question: 'Where are they all collected from?', required: true },
|
||||
{ key: 'pickupcity', question: 'Which city is that pickup in?', required: true },
|
||||
{ key: 'pickuppincode', question: 'And its pincode?', required: true },
|
||||
{
|
||||
key: 'rows',
|
||||
question:
|
||||
'Now paste the recipients — one per line, as:\n\nname, phone, address, city, pincode\n\nPaste them all in one message.',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Assign or reassign one order to a rider.
|
||||
*
|
||||
* The backend already assigns riders on its own (booking creation publishes an
|
||||
* assignment-requested event that a worker picks up within minutes) — this
|
||||
* flow is always an OVERRIDE of a decision the backend may have already made.
|
||||
* That is why the second step re-states who currently holds the order rather
|
||||
* than silently overwriting them: an operator who has not been told is far
|
||||
* more likely to reassign a rider who was already correctly, better-informed,
|
||||
* on their way.
|
||||
*
|
||||
* Unlike the create flows, both steps here need a live lookup (find the order,
|
||||
* then find the rider) rather than a plain string — see `resolve` below.
|
||||
*/
|
||||
const ASSIGN_STEPS = [
|
||||
{
|
||||
key: 'orderRef',
|
||||
question: 'Which order? (the order number, or the DM-… code)',
|
||||
required: true,
|
||||
resolve: async (value) => {
|
||||
const scan = await scanBookings();
|
||||
const needle = String(value).trim().toLowerCase().replace(/^#/, '');
|
||||
const booking = scan.rows.find(
|
||||
(row) =>
|
||||
String(row.bookingid).toLowerCase() === needle ||
|
||||
String(row.bookingno || '').toLowerCase() === needle ||
|
||||
String(row.bookingno || '').toLowerCase().endsWith(needle)
|
||||
);
|
||||
if (!booking) return { error: `No order matches "${value}" — try the order number again.` };
|
||||
return { value: booking };
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'riderRef',
|
||||
/* Names who currently has it, if anyone — the whole point of asking rather
|
||||
than just overwriting. */
|
||||
question: (values) => {
|
||||
const order = values.orderRef;
|
||||
const label = order?.bookingno || `#${order?.bookingid}`;
|
||||
return order?.assignedmileruserid
|
||||
? `${label} is currently assigned. Who should it go to instead?`
|
||||
: `Who should ${label} go to?`;
|
||||
},
|
||||
required: true,
|
||||
resolve: async (value, values) => {
|
||||
const riders = (await getallriders()) || [];
|
||||
const rider = bestNameMatch(value, riders, (r) => r.displayname || r.authname);
|
||||
if (!rider) return { error: `No rider matches "${value}" — try their name again.` };
|
||||
if (String(rider.userid) === String(values.orderRef?.assignedmileruserid)) {
|
||||
return { error: `${rider.displayname || rider.authname} already has this order — name someone else.` };
|
||||
}
|
||||
return { value: rider };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Repeat a previous day's dispatched orders as fresh bookings today.
|
||||
*
|
||||
* One question (which day), then a single automated pass — not a run of
|
||||
* questions — because a booking already carries almost everything
|
||||
* `createExpressBooking` needs, including both sets of coordinates. Only the
|
||||
* recipient's name and phone are missing, and those come from the
|
||||
* `appcustomerid` → `/admin/customers` join, the same one `fetchDeliveries`
|
||||
* already does.
|
||||
*
|
||||
* Duplicate-safety is INVERTED here versus every other flow: near-identical
|
||||
* orders are the *goal*. The only real risk is running the same day's repeat
|
||||
* twice, so the guard fingerprints on `(appcustomerid, delivery address,
|
||||
* pickup pincode)` against TODAY's own bookings, not against the source day.
|
||||
*
|
||||
* Prices are re-quoted at today's tariff — `getAdminPricing` + the same
|
||||
* OSRM-distance-and-tariff formula `CreateOrder.jsx` already uses — never
|
||||
* copied from the original booking, since a tariff can have changed since.
|
||||
* Cancelled orders are never repeated, and the lookback is 7 days.
|
||||
*/
|
||||
const REPEAT_STEPS = [
|
||||
{
|
||||
key: 'sourceDay',
|
||||
question: 'Which day should I repeat? (say "yesterday", or a date like 2026-08-20)',
|
||||
required: true,
|
||||
resolve: async (value) => {
|
||||
const day = dayFromWords(value);
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
if (day > today) return { error: 'That is in the future — nothing to repeat yet.' };
|
||||
if (day < dayjs().subtract(7, 'day').format('YYYY-MM-DD')) {
|
||||
return { error: 'That is more than 7 days back — repeat only looks at the last week.' };
|
||||
}
|
||||
|
||||
const [sourceScan, todayScan, customers] = await Promise.all([
|
||||
fetchBookingsForDay(day),
|
||||
fetchBookingsForDay(today),
|
||||
getAdminCustomers().catch(() => []),
|
||||
]);
|
||||
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
|
||||
const fingerprint = (booking) =>
|
||||
`${booking.appcustomerid}|${String(booking.deliveryaddress || '').toLowerCase().trim()}|${booking.pickuppincode || ''}`;
|
||||
const alreadyRepeated = new Set(todayScan.rows.map(fingerprint));
|
||||
|
||||
const dispatched = sourceScan.rows.filter((b) => b.assignedmileruserid || b.consignmentid);
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
dispatched.forEach((booking) => {
|
||||
/* Cancelled orders are never repeated — not even reported as skipped,
|
||||
since there is nothing an operator would do about that reason. */
|
||||
if (isCancelled(booking)) return;
|
||||
if (alreadyRepeated.has(fingerprint(booking))) {
|
||||
skipped.push({ booking, reason: 'already repeated today' });
|
||||
return;
|
||||
}
|
||||
if (!booking.pickupaddress) {
|
||||
skipped.push({ booking, reason: 'no pickup address recorded on the original order' });
|
||||
return;
|
||||
}
|
||||
if (booking.tenantid == null) {
|
||||
skipped.push({ booking, reason: 'no client recorded on the original order' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(Number(booking.deliverylatitude)) || !Number.isFinite(Number(booking.deliverylongitude))) {
|
||||
skipped.push({ booking, reason: 'delivery address has no saved coordinates' });
|
||||
return;
|
||||
}
|
||||
const customer = customerMap.get(booking.appcustomerid);
|
||||
const phone = customer?.phone || customer?.contactno;
|
||||
if (!phone) {
|
||||
skipped.push({ booking, reason: 'no customer contact number on file' });
|
||||
return;
|
||||
}
|
||||
candidates.push({ booking, customer_name: customer.firstname || customer.name || '', customer_phone: phone });
|
||||
});
|
||||
|
||||
if (!candidates.length) {
|
||||
return {
|
||||
error: skipped.length
|
||||
? `None of the ${skipped.length} dispatched order(s) from ${day} can be repeated — every one is missing something.`
|
||||
: `No dispatched orders were found on ${day}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/* Re-quoted per candidate, not fetched once for the day — a repeat can
|
||||
span more than one client, and each client's own pricing row decides
|
||||
the number (same reasoning as the bulk-create flow's per-tenant rate
|
||||
lookup, just per-row here instead of per-file). */
|
||||
const pricing = await getAdminPricing().catch(() => []);
|
||||
const priced = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
const rate = pricing.find((row) => String(row.tenantid) === String(candidate.booking.tenantid));
|
||||
if (!rate) return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'no pricing configured for this client — used the previous price' };
|
||||
try {
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: candidate.booking.pickuplatitude, longitude: candidate.booking.pickuplongitude },
|
||||
{ latitude: candidate.booking.deliverylatitude, longitude: candidate.booking.deliverylongitude }
|
||||
);
|
||||
const price = calculateTotalCharge(km, Number(rate.baseprice) || 0, Number(rate.priceperkm) || 0, Number(rate.basedistance) || 0);
|
||||
return { ...candidate, finalprice: Number(price.toFixed(2)) };
|
||||
} catch {
|
||||
return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'could not re-measure the route — used the previous price' };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { value: { day, candidates: priced, skipped } };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const FLOWS = {
|
||||
customer: { title: 'New customer', steps: CUSTOMER_STEPS },
|
||||
order: { title: 'New order', steps: ORDER_STEPS },
|
||||
bulk: { title: 'Bulk orders', steps: BULK_STEPS },
|
||||
assign: { title: 'Assign a rider', steps: ASSIGN_STEPS },
|
||||
repeat: { title: 'Repeat a run', steps: REPEAT_STEPS },
|
||||
};
|
||||
|
||||
/* "assign"/"reassign" + rider word, or "assign order/it/this/DM-…", or "change
|
||||
the rider" — matched ahead of the create-verb gate below since "assign" is
|
||||
not one of those verbs. */
|
||||
const ASSIGN_TRIGGER =
|
||||
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it\b|this\b|that\b|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
|
||||
|
||||
/* "repeat yesterday('s run/orders)", "same orders as yesterday", "redo
|
||||
yesterday" — also matched ahead of the create-verb gate, since "repeat" and
|
||||
"redo" are not among those verbs either. */
|
||||
const REPEAT_TRIGGER =
|
||||
/\brepeat\s+(?:yesterday|today|the\s+run|last\s+\w+|orders?)\b|\bsame\s+orders?\s+as\s+(?:yesterday|last\s+\w+)\b|\bredo\s+(?:yesterday|the\s+run)\b/i;
|
||||
|
||||
/** Which flow, if any, a question is asking to start. */
|
||||
export const detectFlow = (text) => {
|
||||
const lower = String(text || '').toLowerCase();
|
||||
if (ASSIGN_TRIGGER.test(lower)) return 'assign';
|
||||
if (REPEAT_TRIGGER.test(lower)) return 'repeat';
|
||||
if (!/\b(create|add|new|make|place|raise|book)\b/.test(lower)) return null;
|
||||
if (/\bcustomer\b/.test(lower)) return 'customer';
|
||||
if (/\b(bulk|multiple|many|batch of)\b/.test(lower) && /\border/.test(lower)) return 'bulk';
|
||||
if (/\b(order|booking|delivery)\b/.test(lower)) return 'order';
|
||||
return null;
|
||||
};
|
||||
|
||||
export const startFlow = (kind) => {
|
||||
const flow = FLOWS[kind];
|
||||
if (!flow) return null;
|
||||
const first = flow.steps[0];
|
||||
const question = typeof first.question === 'function' ? first.question({}) : first.question;
|
||||
return { kind, title: flow.title, stepIndex: 0, values: {}, question };
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies one reply and returns the next state.
|
||||
*
|
||||
* Returns `{ flow }` while still gathering, or `{ flow, review }` once every
|
||||
* step is answered — `review` is the exact payload that will be sent, which the
|
||||
* operator confirms before anything is written.
|
||||
*
|
||||
* Async because a step's `resolve` (assign flow's order/rider lookup) may need
|
||||
* a live API call; the create flows' steps have no `resolve` and pass through
|
||||
* exactly as before.
|
||||
*/
|
||||
export const advanceFlow = async (flow, reply) => {
|
||||
const steps = FLOWS[flow.kind].steps;
|
||||
const step = steps[flow.stepIndex];
|
||||
const raw = String(reply || '').trim();
|
||||
const skipped = /^(skip|none|no|n\/a)$/i.test(raw);
|
||||
const value = skipped ? '' : raw;
|
||||
|
||||
if (step.required && !value) {
|
||||
return { flow, error: 'That one is required — please answer it.' };
|
||||
}
|
||||
if (value && step.validate) {
|
||||
const problem = step.validate(value);
|
||||
if (problem) return { flow, error: problem };
|
||||
}
|
||||
|
||||
let resolved = value;
|
||||
if (value && step.resolve) {
|
||||
const outcome = await step.resolve(value, flow.values);
|
||||
if (outcome.error) return { flow, error: outcome.error };
|
||||
resolved = outcome.value;
|
||||
}
|
||||
|
||||
const values = { ...flow.values, [step.key]: resolved };
|
||||
const nextIndex = flow.stepIndex + 1;
|
||||
|
||||
if (nextIndex < steps.length) {
|
||||
const nextStep = steps[nextIndex];
|
||||
const question = typeof nextStep.question === 'function' ? nextStep.question(values) : nextStep.question;
|
||||
return { flow: { ...flow, stepIndex: nextIndex, values, question } };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, stepIndex: nextIndex, values, question: null }, review: buildReview(flow.kind, values) };
|
||||
};
|
||||
|
||||
/** A human-readable summary plus the payload that will actually be sent. */
|
||||
const buildReview = (kind, values) => {
|
||||
if (kind === 'repeat') {
|
||||
const { day, candidates, skipped } = values.sourceDay;
|
||||
return {
|
||||
kind,
|
||||
title: `Repeat ${candidates.length} order${candidates.length === 1 ? '' : 's'} from ${day}?`,
|
||||
lines: [
|
||||
['Source day', day],
|
||||
['Will create', `${candidates.length} order${candidates.length === 1 ? '' : 's'}`],
|
||||
skipped.length ? ['Skipping', `${skipped.length} — see below`] : null,
|
||||
].filter(Boolean),
|
||||
preview: [
|
||||
...candidates
|
||||
.slice(0, 5)
|
||||
.map((c) => `${c.customer_name || 'Customer'} · ${c.booking.deliveryaddress} · ${formatRupees(c.finalprice)}`),
|
||||
...skipped.slice(0, 3).map((s) => `Skipped ${s.booking.bookingno || `#${s.booking.bookingid}`} — ${s.reason}`),
|
||||
],
|
||||
values: { day, candidates, skipped },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'assign') {
|
||||
const order = values.orderRef;
|
||||
const rider = values.riderRef;
|
||||
return {
|
||||
kind,
|
||||
title: 'Assign this order?',
|
||||
lines: [
|
||||
['Order', order.bookingno || `#${order.bookingid}`],
|
||||
['To', rider.displayname || rider.authname],
|
||||
['Phone', rider.phone],
|
||||
].filter(([, v]) => v),
|
||||
values: {
|
||||
bookingid: order.bookingid,
|
||||
bookingLabel: order.bookingno || `#${order.bookingid}`,
|
||||
mileruserid: rider.userid,
|
||||
milerprofileid: rider.milerprofileid,
|
||||
riderName: rider.displayname || rider.authname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'customer') {
|
||||
return {
|
||||
kind,
|
||||
title: 'Create this customer?',
|
||||
lines: [
|
||||
['Name', [values.firstname, values.lastname].filter(Boolean).join(' ')],
|
||||
['Phone', values.phone],
|
||||
['Email', values.email],
|
||||
].filter(([, v]) => v),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'order') {
|
||||
return {
|
||||
kind,
|
||||
title: 'Create this order?',
|
||||
lines: [
|
||||
['Client', values.tenant],
|
||||
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
|
||||
['Recipient', `${values.customer_name} · ${values.customer_phone}`],
|
||||
['Drop', `${values.deliveryaddress}, ${values.deliverycity} ${values.deliverypincode}`],
|
||||
['Charge', values.finalprice ? formatRupees(Number(values.finalprice)) : formatRupees(0)],
|
||||
['Notes', values.notes],
|
||||
].filter(([, v]) => v),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseBulkRows(values.rows);
|
||||
return {
|
||||
kind,
|
||||
title: `Create ${parsed.length} order${parsed.length === 1 ? '' : 's'}?`,
|
||||
lines: [
|
||||
['Client', values.tenant],
|
||||
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
|
||||
['Recipients', `${parsed.length} rows`],
|
||||
],
|
||||
preview: parsed.slice(0, 5).map((row) => `${row.customer_name} · ${row.customer_phone} · ${row.deliveryaddress}`),
|
||||
values: { ...values, parsed },
|
||||
};
|
||||
};
|
||||
|
||||
/** `name, phone, address, city, pincode` per line. Blank lines are ignored. */
|
||||
const parseBulkRows = (text) =>
|
||||
String(text || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode] = line
|
||||
.split(',')
|
||||
.map((cell) => cell.trim());
|
||||
return { customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode };
|
||||
})
|
||||
.filter((row) => row.customer_name && row.customer_phone);
|
||||
|
||||
const resolveTenantId = async (name) => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
const tenant = bestNameMatch(name, tenants, (t) => t.tenantname);
|
||||
return tenant?.tenantid ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The only mutating function in this module. Called from the review card's
|
||||
* button and from nowhere else.
|
||||
*/
|
||||
export const executeFlow = async (review) => {
|
||||
const { kind, values } = review;
|
||||
|
||||
if (kind === 'repeat') {
|
||||
const bookings = values.candidates.map(({ booking, customer_name, customer_phone, finalprice }) => ({
|
||||
tenantid: booking.tenantid,
|
||||
pickupaddress: booking.pickupaddress,
|
||||
pickupcity: booking.pickupcity || '',
|
||||
pickuppincode: booking.pickuppincode || '',
|
||||
pickuplatitude: booking.pickuplatitude,
|
||||
pickuplongitude: booking.pickuplongitude,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
deliveryaddress: booking.deliveryaddress,
|
||||
deliverycity: booking.deliverycity || '',
|
||||
deliverypincode: booking.deliverypincode || '',
|
||||
deliverylatitude: booking.deliverylatitude,
|
||||
deliverylongitude: booking.deliverylongitude,
|
||||
service_option: 'Normal',
|
||||
finalprice,
|
||||
notes: booking.notes || '',
|
||||
parcels: booking.parcels?.length
|
||||
? booking.parcels.map((p) => ({
|
||||
itemcategory: p.itemcategory || 'General',
|
||||
itemdescription: p.itemdescription || 'Order',
|
||||
declaredvalue: p.declaredvalue || 0,
|
||||
}))
|
||||
: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: finalprice }],
|
||||
}));
|
||||
|
||||
/* Repeats are scoped to one calendar day, so this should never approach
|
||||
the bulk endpoint's 200-per-call cap — refused rather than silently
|
||||
truncated on the rare day that does. */
|
||||
if (bookings.length > 200) {
|
||||
return { ok: false, message: `That is ${bookings.length} orders; the bulk endpoint takes 200 at a time.` };
|
||||
}
|
||||
|
||||
const result = await createExpressBookingBulk(bookings);
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
|
||||
|
||||
/* Two response shapes have been seen from this endpoint in practice — read
|
||||
defensively rather than assume one. A shape this doesn't recognise just
|
||||
means the reassignment pass below finds nothing to do; the orders
|
||||
themselves are still created either way. */
|
||||
const created = result?.results || result?.data?.results || [];
|
||||
|
||||
/* Straight back to whoever had it, one order at a time — sequential on
|
||||
purpose, since these are real writes against dispatch records and a
|
||||
burst of concurrent calls makes a partial failure much harder to
|
||||
attribute to the right order. */
|
||||
let reassigned = 0;
|
||||
for (let i = 0; i < values.candidates.length; i += 1) {
|
||||
const riderId = values.candidates[i].booking.assignedmileruserid;
|
||||
const createdId = created[i]?.bookingid ?? created[i]?.id;
|
||||
if (riderId && createdId) {
|
||||
try {
|
||||
await assignMilerToBooking(createdId, { mileruserid: Number(riderId) });
|
||||
reassigned += 1;
|
||||
} catch {
|
||||
/* The create already succeeded — a failed reassignment leaves that
|
||||
order pending rather than undoing it. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [
|
||||
`${bookings.length} order${bookings.length === 1 ? '' : 's'} created from ${values.day}.`,
|
||||
reassigned ? `${reassigned} went straight back to their previous rider.` : null,
|
||||
values.skipped.length ? `${values.skipped.length} from the original day could not be repeated.` : null,
|
||||
].filter(Boolean);
|
||||
return { ok: true, message: parts.join(' ') };
|
||||
}
|
||||
|
||||
if (kind === 'assign') {
|
||||
const result = await assignMilerToBooking(values.bookingid, { mileruserid: Number(values.mileruserid) });
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The order was not assigned.' };
|
||||
|
||||
/* The assignment already landed at this point — a failed push is reported,
|
||||
not treated as the write having failed. */
|
||||
let notified = false;
|
||||
if (values.milerprofileid) {
|
||||
try {
|
||||
await notifyMiler(values.milerprofileid, 'DoormileXpress', 'A new order has been assigned to you.');
|
||||
notified = true;
|
||||
} catch {
|
||||
/* swallowed — reported via the message below instead */
|
||||
}
|
||||
}
|
||||
|
||||
const tail = notified
|
||||
? ' They have been notified.'
|
||||
: values.milerprofileid
|
||||
? ' The notification failed — tell them directly.'
|
||||
: ' This rider has no profile id, so no notification could be sent.';
|
||||
return { ok: true, message: `${values.bookingLabel} is now with ${values.riderName}.${tail}` };
|
||||
}
|
||||
|
||||
if (kind === 'customer') {
|
||||
const result = await createTenantCustomer({
|
||||
firstname: values.firstname,
|
||||
lastname: values.lastname || '',
|
||||
phone: values.phone,
|
||||
email: values.email || '',
|
||||
});
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The customer was not created.' };
|
||||
return {
|
||||
ok: true,
|
||||
message: `${values.firstname} ${values.lastname || ''}`.trim() + ' was added. They appear on the Customers page now.',
|
||||
};
|
||||
}
|
||||
|
||||
const tenantid = await resolveTenantId(values.tenant);
|
||||
if (!tenantid) {
|
||||
return { ok: false, message: `No client matches "${values.tenant}" — nothing was created.` };
|
||||
}
|
||||
|
||||
if (kind === 'order') {
|
||||
const price = Number(values.finalprice) || 0;
|
||||
const result = await createExpressBooking({
|
||||
tenantid,
|
||||
pickupaddress: values.pickupaddress,
|
||||
pickupcity: values.pickupcity,
|
||||
pickuppincode: values.pickuppincode,
|
||||
customer_name: values.customer_name,
|
||||
customer_phone: values.customer_phone,
|
||||
deliveryaddress: values.deliveryaddress,
|
||||
deliverycity: values.deliverycity,
|
||||
deliverypincode: values.deliverypincode,
|
||||
service_option: 'Normal',
|
||||
finalprice: price,
|
||||
notes: values.notes || '',
|
||||
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: price }],
|
||||
});
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The order was not created.' };
|
||||
return { ok: true, message: 'Order created. It is on the Orders page under Pending.' };
|
||||
}
|
||||
|
||||
const bookings = (values.parsed || []).map((row) => ({
|
||||
tenantid,
|
||||
pickupaddress: values.pickupaddress,
|
||||
pickupcity: values.pickupcity,
|
||||
pickuppincode: values.pickuppincode,
|
||||
customer_name: row.customer_name,
|
||||
customer_phone: row.customer_phone,
|
||||
deliveryaddress: row.deliveryaddress || '',
|
||||
deliverycity: row.deliverycity || '',
|
||||
deliverypincode: row.deliverypincode || '',
|
||||
service_option: 'Normal',
|
||||
finalprice: 0,
|
||||
notes: '',
|
||||
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: 0 }],
|
||||
}));
|
||||
|
||||
/* The endpoint takes at most 200 per call — refuse rather than silently
|
||||
truncating the operator's list. */
|
||||
if (bookings.length > 200) {
|
||||
return { ok: false, message: `That is ${bookings.length} rows; this endpoint takes 200 at a time. Split the list.` };
|
||||
}
|
||||
|
||||
const result = await createExpressBookingBulk(bookings);
|
||||
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
|
||||
return { ok: true, message: `${bookings.length} orders created. They are on the Orders page under Pending.` };
|
||||
};
|
||||
@@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => {
|
||||
let lastPageFetched = 1;
|
||||
|
||||
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const next = await getBookingsPageCached(page);
|
||||
lastPageFetched = page;
|
||||
if (!next.rows.length) {
|
||||
@@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => {
|
||||
for (const intent of INTENTS) {
|
||||
const params = intent.match(text);
|
||||
if (!params) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const result = await intent.run(params);
|
||||
if (result) return { ...result, intentId: intent.id, params };
|
||||
}
|
||||
@@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => {
|
||||
if (segments.length < 2) return null;
|
||||
const results = [];
|
||||
for (const segment of segments) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const r = await matchAndRun(segment);
|
||||
if (r) results.push(r);
|
||||
}
|
||||
@@ -2243,7 +2243,7 @@ export async function answerQuestion(text, context = {}) {
|
||||
// WHAT. Embeddings are good at the former and unreliable at the latter.
|
||||
const params = intent?.match(normalized);
|
||||
if (intent && params) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const result = await intent.run(params);
|
||||
if (result) return { ...result, intentId: intent.id, params, routing: routed };
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* What the assistant knows about where the operator is standing.
|
||||
*
|
||||
* Every page offers every question. The assistant answers about orders, riders,
|
||||
* hubs and the rest regardless of which screen is open, so hiding a question
|
||||
* because you happen to be on Dispatch made it look narrower than it is. The
|
||||
* page decides the ORDER — its own questions lead — not the membership.
|
||||
*
|
||||
* Every suggestion here must actually resolve against the intent catalog. A
|
||||
* chip that comes back "I can't answer that yet" is worse than no chip.
|
||||
*/
|
||||
|
||||
const CATALOG = [
|
||||
'How many orders today?',
|
||||
'How many orders are cancelled?',
|
||||
'Morning batch orders',
|
||||
'Revenue this week',
|
||||
'How many riders are active?',
|
||||
'How many clients do we have?',
|
||||
'How many hubs?',
|
||||
'How many vehicles?',
|
||||
'Any open exceptions?',
|
||||
'How many tripsheets are dispatched?',
|
||||
'How many customers?',
|
||||
'How many pricing rules?',
|
||||
'Track consignment DM-CN-1',
|
||||
'Assign a rider to an order',
|
||||
"Repeat yesterday's run",
|
||||
'Compare orders today vs yesterday',
|
||||
'Status of hub Chennai',
|
||||
'Find vehicle TN01AB1234',
|
||||
'How many competitor branches are tracked?',
|
||||
'How many carrier pricing entries do we have?',
|
||||
];
|
||||
|
||||
const BY_ROUTE = [
|
||||
[
|
||||
// Matches both spellings: the board moved to /doormile/control-x, and
|
||||
// /doormile/dispatch still resolves through the redirect in App.jsx, so a
|
||||
// bookmark that lands there should still get this page's suggestions
|
||||
// rather than the default set.
|
||||
/^\/doormile\/(control-x|dispatch)/,
|
||||
'Control X',
|
||||
['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'],
|
||||
],
|
||||
[
|
||||
/^\/doormile\/orders/,
|
||||
'Orders',
|
||||
['How many orders today?', 'Assign a rider to an order', 'How many orders are cancelled?', 'Revenue this week'],
|
||||
],
|
||||
[/^\/doormile\/deliveries/, 'Deliveries', ['How many orders are delivered?', 'Track consignment DM-CN-1', 'Morning batch orders']],
|
||||
[/^\/doormile\/riders/, 'Riders', ['How many riders are active?', 'Where is Murali?']],
|
||||
[/^\/doormile\/tenants/, 'Clients', ['How many clients do we have?', 'How many pricing rules?']],
|
||||
[/^\/doormile\/customers/, 'Customers', ['How many customers?', 'Create a customer']],
|
||||
[/^\/doormile\/pricing/, 'Pricing', ['How many pricing rules?', 'Revenue this week']],
|
||||
[/^\/doormile\/hubs/, 'Hubs', ['How many hubs?', 'Status of hub Chennai', 'How many vehicles?']],
|
||||
[/^\/doormile\/vehicles/, 'Vehicles', ['How many vehicles?', 'Find vehicle TN01AB1234', 'How many hubs?']],
|
||||
[/^\/doormile\/tripsheets/, 'Tripsheets', ['How many tripsheets are dispatched?', 'How many consignments?']],
|
||||
[/^\/doormile\/exceptions/, 'Exceptions', ['Any open exceptions?', 'How many consignments?']],
|
||||
[
|
||||
/^\/doormile\/competitive-intel/,
|
||||
'Competitive Intel',
|
||||
['How many competitor branches are tracked?', 'How many carrier pricing entries do we have?'],
|
||||
],
|
||||
[/^\/doormile\/reports/, 'Reports', ['Revenue this week', 'Compare orders today vs yesterday', 'How many orders today?']],
|
||||
];
|
||||
|
||||
/** `{ label, suggestions }` for a route — its own questions first, then the rest. */
|
||||
export const getPageContext = (pathname) => {
|
||||
const hit = BY_ROUTE.find(([pattern]) => pattern.test(pathname || ''));
|
||||
const label = hit ? hit[1] : 'Console';
|
||||
const leading = hit ? hit[2] : [];
|
||||
/* Deduplicated, leading questions first — one flat list means one place a
|
||||
question can live. */
|
||||
const suggestions = [...new Set([...leading, ...CATALOG])];
|
||||
return { label, suggestions };
|
||||
};
|
||||
|
||||
export const CHIP_LABELS = {
|
||||
'How many orders today?': 'Orders today',
|
||||
'How many orders are cancelled?': 'Cancelled',
|
||||
'Morning batch orders': 'Morning batch',
|
||||
'Revenue this week': 'Revenue',
|
||||
'How many riders are active?': 'Active riders',
|
||||
'How many clients do we have?': 'Clients',
|
||||
'How many hubs?': 'Hubs',
|
||||
'How many vehicles?': 'Vehicles',
|
||||
'Any open exceptions?': 'Exceptions',
|
||||
'How many tripsheets are dispatched?': 'Tripsheets',
|
||||
'How many customers?': 'Customers',
|
||||
'How many pricing rules?': 'Pricing rules',
|
||||
'How many orders are delivered?': 'Delivered',
|
||||
'Where is Murali?': 'Locate Murali',
|
||||
'Create a customer': 'New customer',
|
||||
'How many consignments?': 'Consignments',
|
||||
'Track consignment DM-CN-1': 'Track DM-CN-1',
|
||||
'Assign a rider to an order': 'Assign rider',
|
||||
'Repeat yesterday\'s run': 'Repeat run',
|
||||
'Compare orders today vs yesterday': 'Compare orders',
|
||||
'Status of hub Chennai': 'Chennai hub',
|
||||
'Find vehicle TN01AB1234': 'Find vehicle',
|
||||
'How many competitor branches are tracked?': 'Competitor branches',
|
||||
'How many carrier pricing entries do we have?': 'Carrier pricing'
|
||||
};
|
||||
|
||||
export const ORDER_CREATED = '__orderCreated';
|
||||
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
|
||||
@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
|
||||
if (shouldStop?.()) break;
|
||||
const row = candidates[i];
|
||||
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
|
||||
priced.push(out);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user