updates on the changes and fix on the all the pages
This commit is contained in:
@@ -36,7 +36,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.
|
||||
|
||||
|
||||
@@ -925,50 +925,14 @@ body.dai-docked .astryx-layout-content {
|
||||
padding-inline: 2px;
|
||||
}
|
||||
/* --------------------------------------------------------------------------
|
||||
Trigger (lives in the app TopNav)
|
||||
The trigger's styles lived here and are gone with it.
|
||||
|
||||
`.dai-trigger` / `.dai-trigger-mark` styled DoormileAI/index.jsx, a trigger
|
||||
button component that nothing rendered — AdminLayout has always declared
|
||||
its own button in JSX with Tailwind classes. Component and CSS removed
|
||||
together so neither is left looking like the thing that controls the
|
||||
header button. To change that button, edit AdminLayout.
|
||||
-------------------------------------------------------------------------- */
|
||||
.dai-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
|
||||
30px inside the 40px button. */
|
||||
.dai-trigger-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
.dai-trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.dai-trigger:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.dai-trigger:focus-visible {
|
||||
outline: 2px solid var(--dai-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dai-trigger[data-active='true'] {
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
/* --------------------------------------------------------------------------
|
||||
Responsive
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -1025,8 +989,7 @@ body.dai-docked .astryx-layout-content {
|
||||
.dai-typing span,
|
||||
.dai-shimmer,
|
||||
.dai-suggestion,
|
||||
.dai-send,
|
||||
.dai-trigger {
|
||||
.dai-send {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
@@ -38,7 +37,7 @@ const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [step.id]);
|
||||
|
||||
if (failed) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuHistory,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||
import { IconButton } from '@/components/ds';
|
||||
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
@@ -36,7 +34,7 @@ import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assista
|
||||
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
|
||||
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
|
||||
import { getPageContext, getFollowUps, toChips, ORDER_CREATED, ORDER_CREATED_ASSIGNED } from './pageContext';
|
||||
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
|
||||
import { Spark, TypingIndicator } from './AIParts';
|
||||
import AIWelcome, { SuggestionChips } from './AIWelcome';
|
||||
import AIMessage from './AIMessage';
|
||||
import AIComposer from './AIComposer';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
// ==============================|| Doormile AI — trigger ||============================== //
|
||||
//
|
||||
// Mounted in AppTopNav. Owns only the open/closed state; everything else
|
||||
// lives in AIPanel. Focus returns here when the panel closes, so keyboard
|
||||
// users land back where they started.
|
||||
|
||||
const DoormileAITrigger = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content="MileTruth">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger"
|
||||
data-active={isOpen}
|
||||
aria-label="Open MileTruth"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoormileAITrigger;
|
||||
@@ -66,6 +66,9 @@ export const CHIP_LABELS = {
|
||||
'Total revenue this week': 'Revenue this week',
|
||||
'How many customers do we have?': 'Customers',
|
||||
'Assign a rider': 'Assign order',
|
||||
'How many tripsheets are dispatched?': 'Tripsheets',
|
||||
'How many open exceptions?': 'Open exceptions',
|
||||
'How many pricing rules are configured?': 'Pricing rules',
|
||||
"Repeat yesterday's orders": 'Repeat yesterday'
|
||||
};
|
||||
|
||||
@@ -167,6 +170,73 @@ const DISPATCH = {
|
||||
]
|
||||
};
|
||||
|
||||
// Linehaul and middle-mile runs.
|
||||
const TRIPSHEETS = {
|
||||
label: 'Tripsheets',
|
||||
suggestions: [
|
||||
{ icon: LuLayers, text: 'How many tripsheets are dispatched?' },
|
||||
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
||||
{ icon: LuBike, text: 'How many riders are active?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuBuilding2, text: 'Current hub status' }]
|
||||
};
|
||||
|
||||
// The register of things that went wrong.
|
||||
const EXCEPTIONS = {
|
||||
label: 'Exceptions',
|
||||
suggestions: [
|
||||
{ icon: LuCircleDot, text: 'How many open exceptions?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: []
|
||||
};
|
||||
|
||||
// Rate cards. Reached from the account menu rather than the bar, but it is a
|
||||
// real page and deserves its own questions like any other.
|
||||
const PRICING = {
|
||||
label: 'Pricing',
|
||||
suggestions: [
|
||||
{ icon: LuBanknote, text: 'How many pricing rules are configured?' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
|
||||
};
|
||||
|
||||
// Bookings — what customers raised in the app, before it becomes an order.
|
||||
//
|
||||
// This page was not in ROUTES at all, so it fell through to DEFAULT_CONTEXT
|
||||
// and offered "How many riders are active?" and "Current hub status" beside a
|
||||
// table of B2C bookings. It is a main nav destination, so that was the most
|
||||
// visible instance of the problem this change fixes.
|
||||
//
|
||||
// No create actions: a booking here originates in the customer app, not in
|
||||
// this console, so "Create an order" would offer to make a different kind of
|
||||
// thing than the page shows.
|
||||
//
|
||||
// One honest limitation: these intents count ALL bookings, not only the
|
||||
// customer-app ones this page lists. They are the right questions to ask from
|
||||
// here and every one of them resolves, but the number will not match the row
|
||||
// count when the page is filtered to a source.
|
||||
const BOOKINGS = {
|
||||
label: 'Bookings',
|
||||
suggestions: [
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
||||
]
|
||||
};
|
||||
|
||||
const TENANTS = {
|
||||
label: 'Tenants',
|
||||
suggestions: [
|
||||
@@ -283,6 +353,7 @@ const FOLLOW_UPS = {
|
||||
const ROUTES = [
|
||||
['/doormile/control-x', DISPATCH],
|
||||
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
|
||||
['/doormile/bookings', BOOKINGS],
|
||||
['/doormile/orders', ORDERS],
|
||||
['/doormile/milers', RIDERS],
|
||||
['/doormile/riders', RIDERS],
|
||||
@@ -303,9 +374,19 @@ const ROUTES = [
|
||||
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
|
||||
}
|
||||
],
|
||||
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
|
||||
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
|
||||
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
|
||||
// These three were `{ ...DEFAULT_CONTEXT, label: '…' }` — a page-specific
|
||||
// NAME over the generic operations questions, so Tripsheets offered "Active
|
||||
// riders" and "Hub status" and nothing about tripsheets. The label made it
|
||||
// look intentional.
|
||||
//
|
||||
// Each now leads with its own resource. The phrasings come from the intents'
|
||||
// own documented examples (tripsheetStatus, exceptionStatus, pricingCount)
|
||||
// and were checked against their `match` predicates before being added here,
|
||||
// per the rule at the top of this file. None contains an order/booking word,
|
||||
// so none is shadowed by the order intents that run earlier in the catalog.
|
||||
['/doormile/tripsheets', TRIPSHEETS],
|
||||
['/doormile/exceptions', EXCEPTIONS],
|
||||
['/doormile/pricing', PRICING]
|
||||
];
|
||||
|
||||
// ---- every question, from every page ---------------------------------------
|
||||
@@ -322,8 +403,9 @@ const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS
|
||||
|
||||
// Every question declared anywhere. Two jobs: it is the icon index behind
|
||||
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
|
||||
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
|
||||
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
|
||||
// still renders with the right icon). It no longer feeds a global suggestion
|
||||
// pool — see the note below. Do not narrow it: a question dropped from here
|
||||
// loses its icon everywhere, including as a follow-up.
|
||||
const EVERY_QUESTION = (() => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
@@ -338,44 +420,38 @@ const EVERY_QUESTION = (() => {
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The questions that ride along on EVERY page.
|
||||
// There is no longer a set of questions that rides along on every page.
|
||||
//
|
||||
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
|
||||
// Orders page offered "How many tenants do we have?" and "Current hub status"
|
||||
// above the composer, questions an operator working orders is not asking. The
|
||||
// strip is a shortcut, and a shortcut that lists everything is a menu.
|
||||
// GLOBAL_TEXTS / GLOBAL_SUGGESTIONS lived here: a thirteen-question daily
|
||||
// operating picture appended to every context. It had already been narrowed
|
||||
// once, from all twenty-one, because the Orders page was offering "How many
|
||||
// tenants do we have?" above the composer. It is gone entirely now — a page
|
||||
// offers its own questions and nothing else.
|
||||
//
|
||||
// What is left is the daily operating picture: what came in, what is stuck,
|
||||
// what got out, what it earned, and the three ways to put work into the system.
|
||||
//
|
||||
// Dropping a question from THIS list does not remove it from the assistant —
|
||||
// it stays typeable, it stays a follow-up, and it still leads on its own page,
|
||||
// because withGlobalQuestions puts each page's own suggestions first. Hubs
|
||||
// still opens on hub questions; they just no longer follow you to Orders.
|
||||
// EVERY_QUESTION above stays. It is still the icon index behind
|
||||
// QUESTION_BY_TEXT, so a follow-up naming a question that no longer appears
|
||||
// on any page strip still renders with the right icon.
|
||||
// ---------------------------------------------------------------------------
|
||||
const GLOBAL_TEXTS = new Set([
|
||||
"Give me today's operations summary",
|
||||
'How many orders today?',
|
||||
'Which orders are delayed?',
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'How many cancelled orders today?',
|
||||
'Morning Batch orders today',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today',
|
||||
'How many riders are active?',
|
||||
'Create an order',
|
||||
REPEAT_TEXT,
|
||||
'Create multiple orders'
|
||||
]);
|
||||
|
||||
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
|
||||
|
||||
// The page's own suggestions lead; the global set follows, deduplicated.
|
||||
const withGlobalQuestions = (context) => {
|
||||
// A page offers its OWN questions, and only those.
|
||||
//
|
||||
// The global set no longer rides along. Asked for directly: on Orders the
|
||||
// strip should be Orders questions, and the same on every other page.
|
||||
//
|
||||
// Worth being precise about what changed, because it is easy to read this as
|
||||
// a no-op. It very nearly was: every context happens to declare at least four
|
||||
// own suggestions and the panel slices to four, so in practice the globals sat
|
||||
// just past the cut on most pages. That made the current behaviour an accident
|
||||
// of counting rather than a rule — declare a context with three suggestions
|
||||
// and a hub question would surface on it. Now it cannot, whatever the counts.
|
||||
//
|
||||
// Dropping a question from a page's list does not remove it from the
|
||||
// assistant. It stays typeable, it stays a follow-up, and it still leads on
|
||||
// its own page. The strip is a shortcut, not the index.
|
||||
const ownQuestionsOnly = (context) => {
|
||||
const seen = new Set();
|
||||
const suggestions = [];
|
||||
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
|
||||
[...(context.suggestions || []), ...(context.more || [])].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
suggestions.push(q);
|
||||
@@ -385,7 +461,7 @@ const withGlobalQuestions = (context) => {
|
||||
|
||||
export const getPageContext = (pathname = '') => {
|
||||
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
||||
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
|
||||
return ownQuestionsOnly(match ? match[1] : DEFAULT_CONTEXT);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Building2, Check, ChevronDown, MapPin, Search, Sparkles } from 'lucide-react';
|
||||
import { Building2, Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, Send, Sparkles, Trash2, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, IconButton, Spinner, StatusBadge, Surface } from '@/components/ds';
|
||||
import { answerQuestion } from '@/lib/assistant/intents';
|
||||
import { advanceFlow, detectFlow, executeFlow, startFlow } from '@/lib/assistant/flows';
|
||||
import { getPageContext } from '@/lib/assistant/pageContext';
|
||||
import { messageOf } from '@/api/doormile/notify';
|
||||
|
||||
/**
|
||||
* Doormile AI — the operations copilot.
|
||||
*
|
||||
* A right-side slide-over rather than a page: it answers about live data while
|
||||
* the operator stays on whatever screen raised the question.
|
||||
*
|
||||
* Two rules shape this file:
|
||||
*
|
||||
* - **Never put a React element in message state.** Messages are round-tripped
|
||||
* through localStorage, and an element does not survive JSON — `$$typeof` is a
|
||||
* Symbol and is dropped, so the rehydrated value crashes the next render.
|
||||
* Everything in a message is plain data.
|
||||
* - **Assistant turns are not bubbles.** Only the operator's own words get a
|
||||
* bubble. That asymmetry is what keeps this reading as part of the console
|
||||
* rather than a chatbot bolted onto it.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'doormileAssistantThread';
|
||||
const MAX_STORED = 40;
|
||||
|
||||
const loadThread = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveThread = (messages) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages.slice(-MAX_STORED)));
|
||||
} catch {
|
||||
/* A refused write (private mode, quota) costs the history, not the session. */
|
||||
}
|
||||
};
|
||||
|
||||
/** The endpoints an answer actually called — the verifiability contract. */
|
||||
function SourceCalls({ calls }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!calls?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex items-center gap-1 text-caption text-ink-4 transition-colors hover:text-ink-2"
|
||||
>
|
||||
<ChevronDown className={cn('h-3 w-3 transition-transform', open && 'rotate-180')} aria-hidden="true" />
|
||||
{calls.length} source{calls.length === 1 ? '' : 's'}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{calls.map((entry, index) => (
|
||||
<div key={index} className="rounded-md bg-surface-sunken px-2 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="text-[11px] text-ink-2">{entry.target}</code>
|
||||
<StatusBadge
|
||||
status={entry.status === 'error' ? 'cancelled' : 'delivered'}
|
||||
label={entry.status === 'error' ? 'partial' : 'ok'}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{entry.stats && <p className="mt-0.5 text-[11px] text-ink-4">{entry.stats}</p>}
|
||||
{entry.errorMessage && <p className="mt-0.5 text-[11px] text-warning">{entry.errorMessage}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantTurn({ message, onConfirm, confirming }) {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-brand px-3.5 py-2 text-body-sm text-white">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-brand-tint text-brand">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{message.headline && <p className="text-body font-semibold text-ink-1">{message.headline}</p>}
|
||||
|
||||
{message.stats?.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
|
||||
{message.stats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg bg-surface-sunken px-2.5 py-1.5">
|
||||
<p className="text-body-sm font-semibold tabular-nums text-ink-1">{stat.value}</p>
|
||||
<p className="text-[11px] text-ink-3">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.text && (
|
||||
<p className="mt-1.5 whitespace-pre-line text-body-sm leading-relaxed text-ink-2">{message.text}</p>
|
||||
)}
|
||||
|
||||
{/* An ordered trail — a parcel's scan history, most recent last. Plain
|
||||
data like everything else in a message, so it survives the
|
||||
localStorage round trip. */}
|
||||
{message.list?.items?.length > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<p className="text-overline uppercase text-ink-4">{message.list.title}</p>
|
||||
<ol className="mt-1.5 space-y-1">
|
||||
{message.list.items.map((item, index) => (
|
||||
<li key={index} className="flex items-center justify-between gap-3 rounded-md bg-surface-sunken px-2.5 py-1.5">
|
||||
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
||||
{item.meta && <span className="shrink-0 text-[11px] text-ink-4">{item.meta}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.review && (
|
||||
<Surface variant="outline" padding="sm" radius="lg" className="mt-2.5">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{message.review.title}</p>
|
||||
<dl className="mt-2 space-y-1">
|
||||
{message.review.lines.map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between gap-3">
|
||||
<dt className="text-caption text-ink-3">{label}</dt>
|
||||
<dd className="text-right text-caption font-medium text-ink-1">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{message.review.preview?.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5 border-t border-border pt-2">
|
||||
{message.review.preview.map((line, index) => (
|
||||
<li key={index} className="truncate text-[11px] text-ink-3">
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{message.done ? (
|
||||
<p className="mt-2.5 text-caption text-success">Sent.</p>
|
||||
) : (
|
||||
<Button size="sm" className="mt-2.5 w-full" onClick={onConfirm} loading={confirming}>
|
||||
Yes, create it
|
||||
</Button>
|
||||
)}
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<SourceCalls calls={message.sourceCalls} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {any} props */
|
||||
export function AssistantPanel({ open, onClose }) {
|
||||
const location = useLocation();
|
||||
const { label, suggestions } = useMemo(() => getPageContext(location.pathname), [location.pathname]);
|
||||
|
||||
const [messages, setMessages] = useState(loadThread);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [flow, setFlow] = useState(null);
|
||||
/* The previous turn, so a bare "what about yesterday?" can re-run it. */
|
||||
const [context, setContext] = useState({});
|
||||
|
||||
const scrollRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => saveThread(messages), [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, busy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onKey = (event) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const push = useCallback((message) => setMessages((current) => [...current, message]), []);
|
||||
|
||||
const ask = useCallback(
|
||||
async (rawText) => {
|
||||
const text = String(rawText || '').trim();
|
||||
if (!text || busy) return;
|
||||
|
||||
push({ role: 'user', text });
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
/* A flow reply must never reach the router: the router matches text, and
|
||||
a bare answer like a phone number matches no intent. */
|
||||
if (flow) {
|
||||
const { flow: next, review, error } = await advanceFlow(flow, text);
|
||||
if (error) {
|
||||
push({ role: 'assistant', text: error });
|
||||
} else if (review) {
|
||||
setFlow(null);
|
||||
push({ role: 'assistant', headline: 'Ready to send', review });
|
||||
} else {
|
||||
setFlow(next);
|
||||
push({ role: 'assistant', text: next.question });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = detectFlow(text);
|
||||
if (kind) {
|
||||
const started = startFlow(kind);
|
||||
setFlow(started);
|
||||
push({ role: 'assistant', headline: started.title, text: started.question });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await answerQuestion(text, context);
|
||||
if (!result) {
|
||||
push({
|
||||
role: 'assistant',
|
||||
text:
|
||||
'I can’t answer that one yet. I cover orders, deliveries, riders, clients, customers, hubs, vehicles, tripsheets, exceptions and pricing — and I can create a customer or an order.\n\nFor anything about Doormile the company, doormile.com is where that lives.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setContext({ lastIntentId: result.intentId, lastParams: result.params });
|
||||
push({
|
||||
role: 'assistant',
|
||||
headline: result.headline,
|
||||
text: result.detail,
|
||||
stats: result.stats,
|
||||
list: result.list,
|
||||
sourceCalls: result.sourceCalls,
|
||||
});
|
||||
} catch (err) {
|
||||
push({ role: 'assistant', text: messageOf(err, 'Something went wrong reaching the API.') });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, flow, context, push]
|
||||
);
|
||||
|
||||
const confirm = useCallback(
|
||||
async (review, index) => {
|
||||
setConfirming(true);
|
||||
try {
|
||||
const result = await executeFlow(review);
|
||||
setMessages((current) =>
|
||||
current.map((message, i) => (i === index ? { ...message, done: result.ok } : message))
|
||||
);
|
||||
push({ role: 'assistant', text: result.message });
|
||||
} catch (err) {
|
||||
push({ role: 'assistant', text: messageOf(err, 'The write failed — nothing was created.') });
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
},
|
||||
[push]
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setMessages([]);
|
||||
setFlow(null);
|
||||
setContext({});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-ink-1/20 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-label="MileTruth"
|
||||
className="fixed right-0 top-0 z-50 flex h-dvh w-full flex-col border-l border-border bg-surface shadow-xl sm:w-[26rem]"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-brand-tint text-brand">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">MileTruth</p>
|
||||
<p className="truncate text-caption text-ink-3">Operations copilot · {label}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{messages.length > 0 && (
|
||||
<IconButton label="Clear conversation" icon={Trash2} variant="ghost" size="sm" onClick={reset} />
|
||||
)}
|
||||
<IconButton label="Close" icon={X} variant="ghost" size="sm" onClick={onClose} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-body-sm text-ink-2">
|
||||
Ask about anything live in this console. Every answer comes from the same endpoints the pages call, and
|
||||
shows which ones.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{suggestions.slice(0, 6).map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => ask(suggestion)}
|
||||
className="w-full rounded-lg border border-border bg-surface-subtle px-3 py-2 text-left text-body-sm
|
||||
text-ink-2 transition-colors hover:border-brand/40 hover:bg-brand-tint/40"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
<AssistantTurn
|
||||
key={index}
|
||||
message={message}
|
||||
confirming={confirming}
|
||||
onConfirm={() => confirm(message.review, index)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{busy && (
|
||||
<div className="flex items-center gap-2 text-caption text-ink-3">
|
||||
<Spinner size="sm" /> Checking the live data…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
ask(input);
|
||||
}}
|
||||
className="border-t border-border p-3"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
/* Enter sends, Shift+Enter breaks the line — the convention an
|
||||
operator typing quickly already expects. */
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
ask(input);
|
||||
}
|
||||
}}
|
||||
placeholder={flow ? 'Type your answer…' : 'Ask about orders, riders, hubs…'}
|
||||
className="max-h-32 min-h-[2.25rem] flex-1 resize-none rounded-lg border border-border bg-surface-subtle
|
||||
px-3 py-2 text-body-sm text-ink-1 placeholder:text-ink-4 outline-none
|
||||
focus:border-brand focus:bg-surface focus:ring-2 focus:ring-brand/15"
|
||||
/>
|
||||
<IconButton label="Send" icon={Send} type="submit" size="sm" disabled={!input.trim() || busy} />
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ const AddressAutocomplete = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined && value !== inputValue) setInputValue(value || '');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [value]);
|
||||
|
||||
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
|
||||
@@ -84,7 +84,7 @@ const AddressAutocomplete = ({
|
||||
}
|
||||
setLoading(true);
|
||||
fetchPredictions(inputValue);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [inputValue, biasKey]);
|
||||
|
||||
// Close the list on an outside click.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Activity, Bell, Bike, Building2, Car, ChevronDown, Coins, FileSpreadsheet,
|
||||
Activity, Bell, Bike, Car, ChevronDown, Coins, FileSpreadsheet,
|
||||
FileText, IndianRupee, ListTodo, LogOut, Menu, Search, Settings, Shield,
|
||||
ShieldAlert, User, UserCheck, Users, Warehouse,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DOORMILE_MARK_URL, DOORMILE_WORDMARK_URL } from '@/assets/brand';
|
||||
import { DOORMILE_MARK_URL } from '@/assets/brand';
|
||||
import { Avatar } from '@/components/ds/Avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -416,30 +416,48 @@ export default function AdminLayout() {
|
||||
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
|
||||
aria-expanded={assistantOpen}
|
||||
className={cn(
|
||||
'grid h-8 w-8 place-items-center rounded-lg transition-colors cursor-pointer',
|
||||
'grid h-8 w-8 place-items-center rounded-lg transition-all cursor-pointer',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
|
||||
// A RING, not a background fill.
|
||||
//
|
||||
// The fill was what capped the mark's size: it renders
|
||||
// behind the image, so a larger mark covered it and the
|
||||
// control looked dead on hover. That is why the icon sat at
|
||||
// 20px, then 24px — each bump ate the only affordance the
|
||||
// button had.
|
||||
//
|
||||
// A ring draws at the button's edge instead, so it stays
|
||||
// visible whatever the mark's size, and the icon is free to
|
||||
// be as large as the 32px box allows. The tint is kept
|
||||
// underneath for the open state, where it still shows
|
||||
// around the mark's transparent corners.
|
||||
assistantOpen
|
||||
? 'bg-brand-tint text-brand ring-2 ring-brand/30'
|
||||
: 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1 hover:ring-2 hover:ring-brand/20'
|
||||
)}
|
||||
>
|
||||
{/* 24px inside a 32px button.
|
||||
{/* 28px, filling the 32px button.
|
||||
|
||||
Started at `h-9 w-9 max-w-none` — a 36px image in a 32px
|
||||
button, so the mark overflowed its own control by 2px on
|
||||
every side. That broke the row's alignment against the
|
||||
bell and avatar AND covered the button completely, leaving
|
||||
`hover:bg-surface-sunken` nowhere to render, so the control
|
||||
looked dead on hover while its neighbours lit up.
|
||||
The size history is worth keeping, because each step was
|
||||
forced by the hover treatment rather than by taste. It
|
||||
began at `h-9 w-9 max-w-none` — 36px inside a 32px button,
|
||||
overflowing its own control by 2px on every side, which
|
||||
broke the row's alignment against the bell and avatar and
|
||||
covered the button so the hover fill had nowhere to show.
|
||||
20px fixed both and read as too faint. 24px was as far as
|
||||
it could go while a background fill was still the
|
||||
affordance.
|
||||
|
||||
Dropped to 20px to fix both, which read as too faint next
|
||||
to the bell. 24px is the ceiling that keeps the fix: it
|
||||
leaves 4px of button on every side, which is what the
|
||||
hover tint needs to be visible as a ring around the mark.
|
||||
Anything larger and the tint disappears again.
|
||||
Moving that affordance to a ring (see the button above)
|
||||
removed the cap, so this is sized for legibility rather
|
||||
than around a constraint: 28px in a 32px box, and
|
||||
comfortably larger than the Bell's 16px — which is right,
|
||||
because a brand mark carrying its own circular frame reads
|
||||
smaller than its box where a stroked icon reads full-size.
|
||||
|
||||
Bigger than the Bell's 16px on purpose — this is a brand
|
||||
mark carrying its own circular frame, so it reads smaller
|
||||
than its box, where a stroked icon reads full-size. */}
|
||||
<img className="h-6 w-6 object-contain" src={doormileMark} alt="" aria-hidden="true" />
|
||||
`object-contain` with no `max-w-none`, so it can never
|
||||
overflow the button again however the asset changes. */}
|
||||
<img className="h-7 w-7 object-contain" src={doormileMark} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, KeyRound, Mail, Shield, User } from 'lucide-react';
|
||||
import { Building2, KeyRound, Mail, Shield } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Button, Field, Grid, Input, PageHeader, SectionHeader, Stack, Surface,
|
||||
Avatar, Button, Field, Grid, Input, SectionHeader, Stack, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useProfile, useUpdatePassword } from '@/lib/doormileHooks';
|
||||
import { orDash } from '@/lib/doormileFormat';
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet,
|
||||
Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle,
|
||||
Ban, Bike,
|
||||
Package, PackageSearch, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
import { drainBookings } from '@/lib/assistant/scan';
|
||||
import {
|
||||
useBooking, useAssignMilerToBooking, useRiderOptions,
|
||||
useCancelBooking, useBulkCancelBookings,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Building2 } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Users } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, PageHeader, SectionHeader, Stack, Surface,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react';
|
||||
import { Ban, Bell, Bike, FileSpreadsheet, PackageSearch, Truck } from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, Input,
|
||||
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton,
|
||||
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
||||
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
|
||||
} from '@/components/ds';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -22,28 +22,21 @@ import {
|
||||
Send,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Table,
|
||||
Upload,
|
||||
Users,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OpenToast } from '@/components/third-party/OpenToast';
|
||||
import { answerQuestion, EXAMPLE_QUESTIONS, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents';
|
||||
import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions';
|
||||
import { answerQuestion } from '@/lib/assistant/intents';
|
||||
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions';
|
||||
import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions';
|
||||
import { startCustomerFlow, answerStep } from '@/lib/assistant/customerFlow';
|
||||
import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow';
|
||||
import { startBulkFlow, answerBulkStep } from '@/lib/assistant/bulkFlow';
|
||||
import { startAssignFlow, answerAssignStep } from '@/lib/assistant/assignFlow';
|
||||
import { answerAssignStep } from '@/lib/assistant/assignFlow';
|
||||
import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions';
|
||||
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
|
||||
import { buildRepeatRun, describeDay, findRecentRuns } from '@/lib/assistant/repeatRuns';
|
||||
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
|
||||
|
||||
const LOGO_FILTER = {
|
||||
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react';
|
||||
import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Upload, UserCheck } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
|
||||
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Bike } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Bike } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select,
|
||||
|
||||
Reference in New Issue
Block a user