updates on the changes and fix on the all the pages

This commit is contained in:
2026-09-19 12:09:45 +05:30
parent 8e87947c5f
commit a044afc614
40 changed files with 232 additions and 2559 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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