updates on the changes and fix on the all the pages

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

View File

@@ -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. - **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. - **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. - **`--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. - **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. - **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.

View File

@@ -925,50 +925,14 @@ body.dai-docked .astryx-layout-content {
padding-inline: 2px; 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 Responsive
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -1025,8 +989,7 @@ body.dai-docked .astryx-layout-content {
.dai-typing span, .dai-typing span,
.dai-shimmer, .dai-shimmer,
.dai-suggestion, .dai-suggestion,
.dai-send, .dai-send {
.dai-trigger {
animation: none !important; animation: none !important;
transition: none !important; transition: none !important;
} }

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
@@ -38,7 +37,7 @@ const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
return () => { return () => {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step.id]); }, [step.id]);
if (failed) { if (failed) {

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
import { import {
LuChevronDown, LuChevronDown,
LuHistory, LuHistory,
@@ -19,7 +18,6 @@ import {
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { IconButton } from '@/components/ds'; import { IconButton } from '@/components/ds';
import { OpenToast } from 'components/third-party/OpenToast'; 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 { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns'; import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
import { getPageContext, getFollowUps, toChips, ORDER_CREATED, ORDER_CREATED_ASSIGNED } from './pageContext'; 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 AIWelcome, { SuggestionChips } from './AIWelcome';
import AIMessage from './AIMessage'; import AIMessage from './AIMessage';
import AIComposer from './AIComposer'; import AIComposer from './AIComposer';

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { TextArea } from '@astryxdesign/core/TextArea'; import { TextArea } from '@astryxdesign/core/TextArea';

View File

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

View File

@@ -66,6 +66,9 @@ export const CHIP_LABELS = {
'Total revenue this week': 'Revenue this week', 'Total revenue this week': 'Revenue this week',
'How many customers do we have?': 'Customers', 'How many customers do we have?': 'Customers',
'Assign a rider': 'Assign order', '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' "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 = { const TENANTS = {
label: 'Tenants', label: 'Tenants',
suggestions: [ suggestions: [
@@ -283,6 +353,7 @@ const FOLLOW_UPS = {
const ROUTES = [ const ROUTES = [
['/doormile/control-x', DISPATCH], ['/doormile/control-x', DISPATCH],
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }], ['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
['/doormile/bookings', BOOKINGS],
['/doormile/orders', ORDERS], ['/doormile/orders', ORDERS],
['/doormile/milers', RIDERS], ['/doormile/milers', RIDERS],
['/doormile/riders', RIDERS], ['/doormile/riders', RIDERS],
@@ -303,9 +374,19 @@ const ROUTES = [
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }] more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
} }
], ],
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }], // These three were `{ ...DEFAULT_CONTEXT, label: '…' }` — a page-specific
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }], // NAME over the generic operations questions, so Tripsheets offered "Active
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }] // 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 --------------------------------------- // ---- 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 // 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 // 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 // still renders with the right icon). It no longer feeds a global suggestion
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead. // 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 EVERY_QUESTION = (() => {
const seen = new Set(); const seen = new Set();
const out = []; 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 // GLOBAL_TEXTS / GLOBAL_SUGGESTIONS lived here: a thirteen-question daily
// Orders page offered "How many tenants do we have?" and "Current hub status" // operating picture appended to every context. It had already been narrowed
// above the composer, questions an operator working orders is not asking. The // once, from all twenty-one, because the Orders page was offering "How many
// strip is a shortcut, and a shortcut that lists everything is a menu. // 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, // EVERY_QUESTION above stays. It is still the icon index behind
// what got out, what it earned, and the three ways to put work into the system. // QUESTION_BY_TEXT, so a follow-up naming a question that no longer appears
// // on any page strip still renders with the right icon.
// 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.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
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)); // A page offers its OWN questions, and only those.
//
// The page's own suggestions lead; the global set follows, deduplicated. // The global set no longer rides along. Asked for directly: on Orders the
const withGlobalQuestions = (context) => { // 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 seen = new Set();
const suggestions = []; const suggestions = [];
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => { [...(context.suggestions || []), ...(context.more || [])].forEach((q) => {
if (seen.has(q.text)) return; if (seen.has(q.text)) return;
seen.add(q.text); seen.add(q.text);
suggestions.push(q); suggestions.push(q);
@@ -385,7 +461,7 @@ const withGlobalQuestions = (context) => {
export const getPageContext = (pathname = '') => { export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix)); const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT); return ownQuestionsOnly(match ? match[1] : DEFAULT_CONTEXT);
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// Sequential on purpose. These are writes against real dispatch records, and // Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to // firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom. // report accurately — which order did not land, and to whom.
// eslint-disable-next-line no-restricted-syntax
for (const t of targets) { for (const t of targets) {
try { try {
// eslint-disable-next-line no-await-in-loop
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) }); await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1; assigned += 1;
assignedRiders.add(String(t.mileruserid)); assignedRiders.add(String(t.mileruserid));
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// drops back should feel one buzz, not ten. // drops back should feel one buzz, not ten.
let notified = 0; let notified = 0;
if (lookup) { if (lookup) {
// eslint-disable-next-line no-restricted-syntax
for (const userid of assignedRiders) { for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid); const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) { if (rider?.milerprofileid) {
try { try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(rider.milerprofileid); await notifyRider(rider.milerprofileid);
notified += 1; notified += 1;
} catch { } catch {

View File

@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
if (hasCoords(row)) { if (hasCoords(row)) {
located.push(row); located.push(row);
// eslint-disable-next-line no-continue
continue; continue;
} }
const key = cacheKey(row); const key = cacheKey(row);
if (cache?.has(key)) { if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) }); located.push({ ...row, ...cache.get(key) });
// eslint-disable-next-line no-continue
continue; continue;
} }
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress }); 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 place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = { const found = {
deliverylatitude: place?.geometry?.location?.lat?.(), 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 // Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast. // 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); if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
} }

View File

@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (String(row.finalprice ?? '') !== '') { if (String(row.finalprice ?? '') !== '') {
out.push(row); out.push(row);
// eslint-disable-next-line no-continue
continue; continue;
} }
if (!match) { if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' }); out.push({ ...row, priceError: 'no pricing configured for this tenant' });
// eslint-disable-next-line no-continue
continue; continue;
} }
// eslint-disable-next-line no-await-in-loop
const km = await calculateDrivingDistance( const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude }, { latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude } { latitude: row.deliverylatitude, longitude: row.deliverylongitude }
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (km == null) { if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' }); out.push({ ...row, priceError: 'could not measure the distance' });
// eslint-disable-next-line no-continue
continue; continue;
} }
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance); 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 batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : ''; const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try { try {
// eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch); const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns // Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } } // { data: { results: [ { index, success, bookingid, bookingno } ] } }
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
status: 'error', status: 'error',
errorMessage: res.message || 'Rejected' errorMessage: res.message || 'Rejected'
}); });
// eslint-disable-next-line no-continue
continue; continue;
} }

View File

@@ -103,7 +103,7 @@ export const advance = (flow) => {
const already = s.field === 'name' ? draft.firstname : draft[s.field]; const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) { if (already) {
step += 1; step += 1;
// eslint-disable-next-line no-continue
continue; continue;
} }
return { flow: { ...flow, step }, ask: s.ask, done: false }; return { flow: { ...flow, step }, ask: s.ask, done: false };

View File

@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
const s = steps[step]; const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) { if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1; step += 1;
// eslint-disable-next-line no-continue
continue; continue;
} }
if (s.auto) { if (s.auto) {
// eslint-disable-next-line no-await-in-loop
const auto = await s.auto(draft); const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch }; if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) { if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value); draft = s.apply(draft, auto.value);
step += 1; step += 1;
// eslint-disable-next-line no-continue
continue; continue;
} }
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false }; return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };

View File

@@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => {
let lastPageFetched = 1; let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) { for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
// eslint-disable-next-line no-await-in-loop
const next = await getBookingsPageCached(page); const next = await getBookingsPageCached(page);
lastPageFetched = page; lastPageFetched = page;
if (!next.rows.length) { if (!next.rows.length) {
@@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => {
for (const intent of INTENTS) { for (const intent of INTENTS) {
const params = intent.match(text); const params = intent.match(text);
if (!params) continue; if (!params) continue;
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params); const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params }; if (result) return { ...result, intentId: intent.id, params };
} }
@@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => {
if (segments.length < 2) return null; if (segments.length < 2) return null;
const results = []; const results = [];
for (const segment of segments) { for (const segment of segments) {
// eslint-disable-next-line no-await-in-loop
const r = await matchAndRun(segment); const r = await matchAndRun(segment);
if (r) results.push(r); 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. // WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized); const params = intent?.match(normalized);
if (intent && params) { if (intent && params) {
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params); const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed }; if (result) return { ...result, intentId: intent.id, params, routing: routed };
} }

View File

@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
if (shouldStop?.()) break; if (shouldStop?.()) break;
const row = candidates[i]; const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name }); 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); const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out); priced.push(out);
} }

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react'; 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 { cn } from '@/lib/utils';
import { import {
DropdownMenu, DropdownMenu,

View File

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

View File

@@ -7,7 +7,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';

View File

@@ -53,7 +53,7 @@ const AddressAutocomplete = ({
useEffect(() => { useEffect(() => {
if (value !== undefined && value !== inputValue) setInputValue(value || ''); if (value !== undefined && value !== inputValue) setInputValue(value || '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]); }, [value]);
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`; const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
@@ -84,7 +84,7 @@ const AddressAutocomplete = ({
} }
setLoading(true); setLoading(true);
fetchPredictions(inputValue); fetchPredictions(inputValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue, biasKey]); }, [inputValue, biasKey]);
// Close the list on an outside click. // Close the list on an outside click.

View File

@@ -1,4 +1,4 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';

View File

@@ -2,12 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
Activity, Bell, Bike, Building2, Car, ChevronDown, Coins, FileSpreadsheet, Activity, Bell, Bike, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, IndianRupee, ListTodo, LogOut, Menu, Search, Settings, Shield, FileText, IndianRupee, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, Users, Warehouse, ShieldAlert, User, UserCheck, Users, Warehouse,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; 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 { Avatar } from '@/components/ds/Avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -416,30 +416,48 @@ export default function AdminLayout() {
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'} aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
aria-expanded={assistantOpen} aria-expanded={assistantOpen}
className={cn( 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', '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 The size history is worth keeping, because each step was
button, so the mark overflowed its own control by 2px on forced by the hover treatment rather than by taste. It
every side. That broke the row's alignment against the began at `h-9 w-9 max-w-none` — 36px inside a 32px button,
bell and avatar AND covered the button completely, leaving overflowing its own control by 2px on every side, which
`hover:bg-surface-sunken` nowhere to render, so the control broke the row's alignment against the bell and avatar and
looked dead on hover while its neighbours lit up. 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 Moving that affordance to a ring (see the button above)
to the bell. 24px is the ceiling that keeps the fix: it removed the cap, so this is sized for legibility rather
leaves 4px of button on every side, which is what the than around a constraint: 28px in a 32px box, and
hover tint needs to be visible as a ring around the mark. comfortably larger than the Bell's 16px — which is right,
Anything larger and the tint disappears again. 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 `object-contain` with no `max-w-none`, so it can never
mark carrying its own circular frame, so it reads smaller overflow the button again however the asset changes. */}
than its box, where a stroked icon reads full-size. */} <img className="h-7 w-7 object-contain" src={doormileMark} alt="" aria-hidden="true" />
<img className="h-6 w-6 object-contain" src={doormileMark} alt="" aria-hidden="true" />
</button> </button>
)} )}

View File

@@ -37,7 +37,8 @@ An in-console Q&A assistant that answers operator questions about live data —
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element. - **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. - **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. - **`--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. - **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. - **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.

File diff suppressed because it is too large Load Diff

View File

@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// Sequential on purpose. These are writes against real dispatch records, and // Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to // firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom. // report accurately — which order did not land, and to whom.
// eslint-disable-next-line no-restricted-syntax
for (const t of targets) { for (const t of targets) {
try { try {
// eslint-disable-next-line no-await-in-loop
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) }); await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1; assigned += 1;
assignedRiders.add(String(t.mileruserid)); assignedRiders.add(String(t.mileruserid));
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// drops back should feel one buzz, not ten. // drops back should feel one buzz, not ten.
let notified = 0; let notified = 0;
if (lookup) { if (lookup) {
// eslint-disable-next-line no-restricted-syntax
for (const userid of assignedRiders) { for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid); const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) { if (rider?.milerprofileid) {
try { try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(rider.milerprofileid); await notifyRider(rider.milerprofileid);
notified += 1; notified += 1;
} catch { } catch {

View File

@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
if (hasCoords(row)) { if (hasCoords(row)) {
located.push(row); located.push(row);
// eslint-disable-next-line no-continue
continue; continue;
} }
const key = cacheKey(row); const key = cacheKey(row);
if (cache?.has(key)) { if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) }); located.push({ ...row, ...cache.get(key) });
// eslint-disable-next-line no-continue
continue; continue;
} }
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress }); 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 place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = { const found = {
deliverylatitude: place?.geometry?.location?.lat?.(), 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 // Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast. // 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); if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
} }

View File

@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (String(row.finalprice ?? '') !== '') { if (String(row.finalprice ?? '') !== '') {
out.push(row); out.push(row);
// eslint-disable-next-line no-continue
continue; continue;
} }
if (!match) { if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' }); out.push({ ...row, priceError: 'no pricing configured for this tenant' });
// eslint-disable-next-line no-continue
continue; continue;
} }
// eslint-disable-next-line no-await-in-loop
const km = await calculateDrivingDistance( const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude }, { latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude } { latitude: row.deliverylatitude, longitude: row.deliverylongitude }
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (km == null) { if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' }); out.push({ ...row, priceError: 'could not measure the distance' });
// eslint-disable-next-line no-continue
continue; continue;
} }
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance); 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 batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : ''; const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try { try {
// eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch); const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns // Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } } // { data: { results: [ { index, success, bookingid, bookingno } ] } }
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
status: 'error', status: 'error',
errorMessage: res.message || 'Rejected' errorMessage: res.message || 'Rejected'
}); });
// eslint-disable-next-line no-continue
continue; continue;
} }

View File

@@ -103,7 +103,7 @@ export const advance = (flow) => {
const already = s.field === 'name' ? draft.firstname : draft[s.field]; const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) { if (already) {
step += 1; step += 1;
// eslint-disable-next-line no-continue
continue; continue;
} }
return { flow: { ...flow, step }, ask: s.ask, done: false }; return { flow: { ...flow, step }, ask: s.ask, done: false };

View File

@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
const s = steps[step]; const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) { if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1; step += 1;
// eslint-disable-next-line no-continue
continue; continue;
} }
if (s.auto) { if (s.auto) {
// eslint-disable-next-line no-await-in-loop
const auto = await s.auto(draft); const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch }; if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) { if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value); draft = s.apply(draft, auto.value);
step += 1; step += 1;
// eslint-disable-next-line no-continue
continue; continue;
} }
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false }; return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };

View File

@@ -1,613 +0,0 @@
import dayjs from 'dayjs';
import {
assignMilerToBooking, createExpressBooking, createExpressBookingBulk, createTenantCustomer,
getAdminCustomers, getAdminPricing, getalltenants, getallriders, notifyMiler,
} from '@/api/doormile';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
import { fetchBookingsForDay, scanBookings } from './scan';
import { bestNameMatch, bookingCharge, dayFromWords, formatRupees, isCancelled } from './vocab';
/**
* The assistant's five writes: a customer, a single order, a batch, a rider
* assignment, and a repeated day.
*
* **All five are conversations**, one question per turn — not forms. That is
* explicit product direction, and there is no create-form component here.
*
* **The write gate is non-negotiable.** A flow gathers, then shows exactly what
* will be sent, and the mutation fires only when the operator presses the
* button. `execute` is the only mutating function in this module and nothing
* calls it from a `match`. No intent can trigger a write.
*
* The panel intercepts a reply *before* the router sees it whenever a flow is
* open. That is load-bearing rather than a tidy-up: the router matches text, and
* a bare answer like `9876543210` matches no intent, so without the intercept
* every reply would be lost to "I can't answer that one yet".
*/
/** Customer creation writes to `/admin/tenantcustomers`.
*
* Settled by evidence: `POST /admin/customers` answers 405 Method Not Allowed —
* the route exists and POST is not among its methods. That resource grows a
* customer as a side effect of a booking, which is also why its records carry
* no address. The Customers page reads tenant customers for the same reason, so
* a customer created here appears there immediately.
*
* Creating one *via a booking* was rejected outright: "add a customer" must
* never silently dispatch a delivery.
*/
const CUSTOMER_STEPS = [
{ key: 'firstname', question: 'What is the customer’s first name?', required: true },
{ key: 'lastname', question: 'And their last name? (say "skip" if you don’t have it)' },
{
key: 'phone',
question: 'Their 10-digit mobile number?',
required: true,
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
},
{ key: 'email', question: 'An email address? (say "skip" if there isn’t one)' },
];
const ORDER_STEPS = [
{ key: 'tenant', question: 'Which client is this order for?', required: true },
{ key: 'pickupaddress', question: 'Where is it collected from?', required: true },
{ key: 'pickupcity', question: 'Which city is the pickup in?', required: true },
{ key: 'pickuppincode', question: 'And the pickup pincode?', required: true },
{ key: 'customer_name', question: 'Who is receiving it?', required: true },
{
key: 'customer_phone',
question: 'Their 10-digit mobile number?',
required: true,
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
},
{ key: 'deliveryaddress', question: 'What is the delivery address?', required: true },
{ key: 'deliverycity', question: 'Which city is the delivery in?', required: true },
{ key: 'deliverypincode', question: 'And the delivery pincode?', required: true },
{
key: 'finalprice',
question: 'What should this be charged at? (a number, or "skip" to send 0)',
validate: (value) =>
value === '' || !Number.isNaN(Number(value)) ? null : 'That is not a number — try again.',
},
{ key: 'notes', question: 'Anything the rider should know? (or "skip")' },
];
const BULK_STEPS = [
{ key: 'tenant', question: 'Which client are these orders for?', required: true },
{ key: 'pickupaddress', question: 'Where are they all collected from?', required: true },
{ key: 'pickupcity', question: 'Which city is that pickup in?', required: true },
{ key: 'pickuppincode', question: 'And its pincode?', required: true },
{
key: 'rows',
question:
'Now paste the recipients — one per line, as:\n\nname, phone, address, city, pincode\n\nPaste them all in one message.',
required: true,
},
];
/**
* Assign or reassign one order to a rider.
*
* The backend already assigns riders on its own (booking creation publishes an
* assignment-requested event that a worker picks up within minutes) — this
* flow is always an OVERRIDE of a decision the backend may have already made.
* That is why the second step re-states who currently holds the order rather
* than silently overwriting them: an operator who has not been told is far
* more likely to reassign a rider who was already correctly, better-informed,
* on their way.
*
* Unlike the create flows, both steps here need a live lookup (find the order,
* then find the rider) rather than a plain string — see `resolve` below.
*/
const ASSIGN_STEPS = [
{
key: 'orderRef',
question: 'Which order? (the order number, or the DM-… code)',
required: true,
resolve: async (value) => {
const scan = await scanBookings();
const needle = String(value).trim().toLowerCase().replace(/^#/, '');
const booking = scan.rows.find(
(row) =>
String(row.bookingid).toLowerCase() === needle ||
String(row.bookingno || '').toLowerCase() === needle ||
String(row.bookingno || '').toLowerCase().endsWith(needle)
);
if (!booking) return { error: `No order matches "${value}" — try the order number again.` };
return { value: booking };
},
},
{
key: 'riderRef',
/* Names who currently has it, if anyone — the whole point of asking rather
than just overwriting. */
question: (values) => {
const order = values.orderRef;
const label = order?.bookingno || `#${order?.bookingid}`;
return order?.assignedmileruserid
? `${label} is currently assigned. Who should it go to instead?`
: `Who should ${label} go to?`;
},
required: true,
resolve: async (value, values) => {
const riders = (await getallriders()) || [];
const rider = bestNameMatch(value, riders, (r) => r.displayname || r.authname);
if (!rider) return { error: `No rider matches "${value}" — try their name again.` };
if (String(rider.userid) === String(values.orderRef?.assignedmileruserid)) {
return { error: `${rider.displayname || rider.authname} already has this order — name someone else.` };
}
return { value: rider };
},
},
];
/**
* Repeat a previous day's dispatched orders as fresh bookings today.
*
* One question (which day), then a single automated pass — not a run of
* questions — because a booking already carries almost everything
* `createExpressBooking` needs, including both sets of coordinates. Only the
* recipient's name and phone are missing, and those come from the
* `appcustomerid` → `/admin/customers` join, the same one `fetchDeliveries`
* already does.
*
* Duplicate-safety is INVERTED here versus every other flow: near-identical
* orders are the *goal*. The only real risk is running the same day's repeat
* twice, so the guard fingerprints on `(appcustomerid, delivery address,
* pickup pincode)` against TODAY's own bookings, not against the source day.
*
* Prices are re-quoted at today's tariff — `getAdminPricing` + the same
* OSRM-distance-and-tariff formula `CreateOrder.jsx` already uses — never
* copied from the original booking, since a tariff can have changed since.
* Cancelled orders are never repeated, and the lookback is 7 days.
*/
const REPEAT_STEPS = [
{
key: 'sourceDay',
question: 'Which day should I repeat? (say "yesterday", or a date like 2026-08-20)',
required: true,
resolve: async (value) => {
const day = dayFromWords(value);
const today = dayjs().format('YYYY-MM-DD');
if (day > today) return { error: 'That is in the future — nothing to repeat yet.' };
if (day < dayjs().subtract(7, 'day').format('YYYY-MM-DD')) {
return { error: 'That is more than 7 days back — repeat only looks at the last week.' };
}
const [sourceScan, todayScan, customers] = await Promise.all([
fetchBookingsForDay(day),
fetchBookingsForDay(today),
getAdminCustomers().catch(() => []),
]);
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
const fingerprint = (booking) =>
`${booking.appcustomerid}|${String(booking.deliveryaddress || '').toLowerCase().trim()}|${booking.pickuppincode || ''}`;
const alreadyRepeated = new Set(todayScan.rows.map(fingerprint));
const dispatched = sourceScan.rows.filter((b) => b.assignedmileruserid || b.consignmentid);
const candidates = [];
const skipped = [];
dispatched.forEach((booking) => {
/* Cancelled orders are never repeated — not even reported as skipped,
since there is nothing an operator would do about that reason. */
if (isCancelled(booking)) return;
if (alreadyRepeated.has(fingerprint(booking))) {
skipped.push({ booking, reason: 'already repeated today' });
return;
}
if (!booking.pickupaddress) {
skipped.push({ booking, reason: 'no pickup address recorded on the original order' });
return;
}
if (booking.tenantid == null) {
skipped.push({ booking, reason: 'no client recorded on the original order' });
return;
}
if (!Number.isFinite(Number(booking.deliverylatitude)) || !Number.isFinite(Number(booking.deliverylongitude))) {
skipped.push({ booking, reason: 'delivery address has no saved coordinates' });
return;
}
const customer = customerMap.get(booking.appcustomerid);
const phone = customer?.phone || customer?.contactno;
if (!phone) {
skipped.push({ booking, reason: 'no customer contact number on file' });
return;
}
candidates.push({ booking, customer_name: customer.firstname || customer.name || '', customer_phone: phone });
});
if (!candidates.length) {
return {
error: skipped.length
? `None of the ${skipped.length} dispatched order(s) from ${day} can be repeated — every one is missing something.`
: `No dispatched orders were found on ${day}.`,
};
}
/* Re-quoted per candidate, not fetched once for the day — a repeat can
span more than one client, and each client's own pricing row decides
the number (same reasoning as the bulk-create flow's per-tenant rate
lookup, just per-row here instead of per-file). */
const pricing = await getAdminPricing().catch(() => []);
const priced = await Promise.all(
candidates.map(async (candidate) => {
const rate = pricing.find((row) => String(row.tenantid) === String(candidate.booking.tenantid));
if (!rate) return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'no pricing configured for this client — used the previous price' };
try {
const km = await calculateDrivingDistance(
{ latitude: candidate.booking.pickuplatitude, longitude: candidate.booking.pickuplongitude },
{ latitude: candidate.booking.deliverylatitude, longitude: candidate.booking.deliverylongitude }
);
const price = calculateTotalCharge(km, Number(rate.baseprice) || 0, Number(rate.priceperkm) || 0, Number(rate.basedistance) || 0);
return { ...candidate, finalprice: Number(price.toFixed(2)) };
} catch {
return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'could not re-measure the route — used the previous price' };
}
})
);
return { value: { day, candidates: priced, skipped } };
},
},
];
const FLOWS = {
customer: { title: 'New customer', steps: CUSTOMER_STEPS },
order: { title: 'New order', steps: ORDER_STEPS },
bulk: { title: 'Bulk orders', steps: BULK_STEPS },
assign: { title: 'Assign a rider', steps: ASSIGN_STEPS },
repeat: { title: 'Repeat a run', steps: REPEAT_STEPS },
};
/* "assign"/"reassign" + rider word, or "assign order/it/this/DM-…", or "change
the rider" — matched ahead of the create-verb gate below since "assign" is
not one of those verbs. */
const ASSIGN_TRIGGER =
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it\b|this\b|that\b|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
/* "repeat yesterday('s run/orders)", "same orders as yesterday", "redo
yesterday" — also matched ahead of the create-verb gate, since "repeat" and
"redo" are not among those verbs either. */
const REPEAT_TRIGGER =
/\brepeat\s+(?:yesterday|today|the\s+run|last\s+\w+|orders?)\b|\bsame\s+orders?\s+as\s+(?:yesterday|last\s+\w+)\b|\bredo\s+(?:yesterday|the\s+run)\b/i;
/** Which flow, if any, a question is asking to start. */
export const detectFlow = (text) => {
const lower = String(text || '').toLowerCase();
if (ASSIGN_TRIGGER.test(lower)) return 'assign';
if (REPEAT_TRIGGER.test(lower)) return 'repeat';
if (!/\b(create|add|new|make|place|raise|book)\b/.test(lower)) return null;
if (/\bcustomer\b/.test(lower)) return 'customer';
if (/\b(bulk|multiple|many|batch of)\b/.test(lower) && /\border/.test(lower)) return 'bulk';
if (/\b(order|booking|delivery)\b/.test(lower)) return 'order';
return null;
};
export const startFlow = (kind) => {
const flow = FLOWS[kind];
if (!flow) return null;
const first = flow.steps[0];
const question = typeof first.question === 'function' ? first.question({}) : first.question;
return { kind, title: flow.title, stepIndex: 0, values: {}, question };
};
/**
* Applies one reply and returns the next state.
*
* Returns `{ flow }` while still gathering, or `{ flow, review }` once every
* step is answered — `review` is the exact payload that will be sent, which the
* operator confirms before anything is written.
*
* Async because a step's `resolve` (assign flow's order/rider lookup) may need
* a live API call; the create flows' steps have no `resolve` and pass through
* exactly as before.
*/
export const advanceFlow = async (flow, reply) => {
const steps = FLOWS[flow.kind].steps;
const step = steps[flow.stepIndex];
const raw = String(reply || '').trim();
const skipped = /^(skip|none|no|n\/a)$/i.test(raw);
const value = skipped ? '' : raw;
if (step.required && !value) {
return { flow, error: 'That one is required — please answer it.' };
}
if (value && step.validate) {
const problem = step.validate(value);
if (problem) return { flow, error: problem };
}
let resolved = value;
if (value && step.resolve) {
const outcome = await step.resolve(value, flow.values);
if (outcome.error) return { flow, error: outcome.error };
resolved = outcome.value;
}
const values = { ...flow.values, [step.key]: resolved };
const nextIndex = flow.stepIndex + 1;
if (nextIndex < steps.length) {
const nextStep = steps[nextIndex];
const question = typeof nextStep.question === 'function' ? nextStep.question(values) : nextStep.question;
return { flow: { ...flow, stepIndex: nextIndex, values, question } };
}
return { flow: { ...flow, stepIndex: nextIndex, values, question: null }, review: buildReview(flow.kind, values) };
};
/** A human-readable summary plus the payload that will actually be sent. */
const buildReview = (kind, values) => {
if (kind === 'repeat') {
const { day, candidates, skipped } = values.sourceDay;
return {
kind,
title: `Repeat ${candidates.length} order${candidates.length === 1 ? '' : 's'} from ${day}?`,
lines: [
['Source day', day],
['Will create', `${candidates.length} order${candidates.length === 1 ? '' : 's'}`],
skipped.length ? ['Skipping', `${skipped.length} — see below`] : null,
].filter(Boolean),
preview: [
...candidates
.slice(0, 5)
.map((c) => `${c.customer_name || 'Customer'} · ${c.booking.deliveryaddress} · ${formatRupees(c.finalprice)}`),
...skipped.slice(0, 3).map((s) => `Skipped ${s.booking.bookingno || `#${s.booking.bookingid}`} — ${s.reason}`),
],
values: { day, candidates, skipped },
};
}
if (kind === 'assign') {
const order = values.orderRef;
const rider = values.riderRef;
return {
kind,
title: 'Assign this order?',
lines: [
['Order', order.bookingno || `#${order.bookingid}`],
['To', rider.displayname || rider.authname],
['Phone', rider.phone],
].filter(([, v]) => v),
values: {
bookingid: order.bookingid,
bookingLabel: order.bookingno || `#${order.bookingid}`,
mileruserid: rider.userid,
milerprofileid: rider.milerprofileid,
riderName: rider.displayname || rider.authname,
},
};
}
if (kind === 'customer') {
return {
kind,
title: 'Create this customer?',
lines: [
['Name', [values.firstname, values.lastname].filter(Boolean).join(' ')],
['Phone', values.phone],
['Email', values.email],
].filter(([, v]) => v),
values,
};
}
if (kind === 'order') {
return {
kind,
title: 'Create this order?',
lines: [
['Client', values.tenant],
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
['Recipient', `${values.customer_name} · ${values.customer_phone}`],
['Drop', `${values.deliveryaddress}, ${values.deliverycity} ${values.deliverypincode}`],
['Charge', values.finalprice ? formatRupees(Number(values.finalprice)) : formatRupees(0)],
['Notes', values.notes],
].filter(([, v]) => v),
values,
};
}
const parsed = parseBulkRows(values.rows);
return {
kind,
title: `Create ${parsed.length} order${parsed.length === 1 ? '' : 's'}?`,
lines: [
['Client', values.tenant],
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
['Recipients', `${parsed.length} rows`],
],
preview: parsed.slice(0, 5).map((row) => `${row.customer_name} · ${row.customer_phone} · ${row.deliveryaddress}`),
values: { ...values, parsed },
};
};
/** `name, phone, address, city, pincode` per line. Blank lines are ignored. */
const parseBulkRows = (text) =>
String(text || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode] = line
.split(',')
.map((cell) => cell.trim());
return { customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode };
})
.filter((row) => row.customer_name && row.customer_phone);
const resolveTenantId = async (name) => {
const tenants = (await getalltenants()) || [];
const tenant = bestNameMatch(name, tenants, (t) => t.tenantname);
return tenant?.tenantid ?? null;
};
/**
* The only mutating function in this module. Called from the review card's
* button and from nowhere else.
*/
export const executeFlow = async (review) => {
const { kind, values } = review;
if (kind === 'repeat') {
const bookings = values.candidates.map(({ booking, customer_name, customer_phone, finalprice }) => ({
tenantid: booking.tenantid,
pickupaddress: booking.pickupaddress,
pickupcity: booking.pickupcity || '',
pickuppincode: booking.pickuppincode || '',
pickuplatitude: booking.pickuplatitude,
pickuplongitude: booking.pickuplongitude,
customer_name,
customer_phone,
deliveryaddress: booking.deliveryaddress,
deliverycity: booking.deliverycity || '',
deliverypincode: booking.deliverypincode || '',
deliverylatitude: booking.deliverylatitude,
deliverylongitude: booking.deliverylongitude,
service_option: 'Normal',
finalprice,
notes: booking.notes || '',
parcels: booking.parcels?.length
? booking.parcels.map((p) => ({
itemcategory: p.itemcategory || 'General',
itemdescription: p.itemdescription || 'Order',
declaredvalue: p.declaredvalue || 0,
}))
: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: finalprice }],
}));
/* Repeats are scoped to one calendar day, so this should never approach
the bulk endpoint's 200-per-call cap — refused rather than silently
truncated on the rare day that does. */
if (bookings.length > 200) {
return { ok: false, message: `That is ${bookings.length} orders; the bulk endpoint takes 200 at a time.` };
}
const result = await createExpressBookingBulk(bookings);
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
/* Two response shapes have been seen from this endpoint in practice — read
defensively rather than assume one. A shape this doesn't recognise just
means the reassignment pass below finds nothing to do; the orders
themselves are still created either way. */
const created = result?.results || result?.data?.results || [];
/* Straight back to whoever had it, one order at a time — sequential on
purpose, since these are real writes against dispatch records and a
burst of concurrent calls makes a partial failure much harder to
attribute to the right order. */
let reassigned = 0;
for (let i = 0; i < values.candidates.length; i += 1) {
const riderId = values.candidates[i].booking.assignedmileruserid;
const createdId = created[i]?.bookingid ?? created[i]?.id;
if (riderId && createdId) {
try {
await assignMilerToBooking(createdId, { mileruserid: Number(riderId) });
reassigned += 1;
} catch {
/* The create already succeeded — a failed reassignment leaves that
order pending rather than undoing it. */
}
}
}
const parts = [
`${bookings.length} order${bookings.length === 1 ? '' : 's'} created from ${values.day}.`,
reassigned ? `${reassigned} went straight back to their previous rider.` : null,
values.skipped.length ? `${values.skipped.length} from the original day could not be repeated.` : null,
].filter(Boolean);
return { ok: true, message: parts.join(' ') };
}
if (kind === 'assign') {
const result = await assignMilerToBooking(values.bookingid, { mileruserid: Number(values.mileruserid) });
if (result?.success === false) return { ok: false, message: result.message || 'The order was not assigned.' };
/* The assignment already landed at this point — a failed push is reported,
not treated as the write having failed. */
let notified = false;
if (values.milerprofileid) {
try {
await notifyMiler(values.milerprofileid, 'DoormileXpress', 'A new order has been assigned to you.');
notified = true;
} catch {
/* swallowed — reported via the message below instead */
}
}
const tail = notified
? ' They have been notified.'
: values.milerprofileid
? ' The notification failed — tell them directly.'
: ' This rider has no profile id, so no notification could be sent.';
return { ok: true, message: `${values.bookingLabel} is now with ${values.riderName}.${tail}` };
}
if (kind === 'customer') {
const result = await createTenantCustomer({
firstname: values.firstname,
lastname: values.lastname || '',
phone: values.phone,
email: values.email || '',
});
if (result?.success === false) return { ok: false, message: result.message || 'The customer was not created.' };
return {
ok: true,
message: `${values.firstname} ${values.lastname || ''}`.trim() + ' was added. They appear on the Customers page now.',
};
}
const tenantid = await resolveTenantId(values.tenant);
if (!tenantid) {
return { ok: false, message: `No client matches "${values.tenant}" — nothing was created.` };
}
if (kind === 'order') {
const price = Number(values.finalprice) || 0;
const result = await createExpressBooking({
tenantid,
pickupaddress: values.pickupaddress,
pickupcity: values.pickupcity,
pickuppincode: values.pickuppincode,
customer_name: values.customer_name,
customer_phone: values.customer_phone,
deliveryaddress: values.deliveryaddress,
deliverycity: values.deliverycity,
deliverypincode: values.deliverypincode,
service_option: 'Normal',
finalprice: price,
notes: values.notes || '',
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: price }],
});
if (result?.success === false) return { ok: false, message: result.message || 'The order was not created.' };
return { ok: true, message: 'Order created. It is on the Orders page under Pending.' };
}
const bookings = (values.parsed || []).map((row) => ({
tenantid,
pickupaddress: values.pickupaddress,
pickupcity: values.pickupcity,
pickuppincode: values.pickuppincode,
customer_name: row.customer_name,
customer_phone: row.customer_phone,
deliveryaddress: row.deliveryaddress || '',
deliverycity: row.deliverycity || '',
deliverypincode: row.deliverypincode || '',
service_option: 'Normal',
finalprice: 0,
notes: '',
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: 0 }],
}));
/* The endpoint takes at most 200 per call — refuse rather than silently
truncating the operator's list. */
if (bookings.length > 200) {
return { ok: false, message: `That is ${bookings.length} rows; this endpoint takes 200 at a time. Split the list.` };
}
const result = await createExpressBookingBulk(bookings);
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
return { ok: true, message: `${bookings.length} orders created. They are on the Orders page under Pending.` };
};

View File

@@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => {
let lastPageFetched = 1; let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) { for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
// eslint-disable-next-line no-await-in-loop
const next = await getBookingsPageCached(page); const next = await getBookingsPageCached(page);
lastPageFetched = page; lastPageFetched = page;
if (!next.rows.length) { if (!next.rows.length) {
@@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => {
for (const intent of INTENTS) { for (const intent of INTENTS) {
const params = intent.match(text); const params = intent.match(text);
if (!params) continue; if (!params) continue;
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params); const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params }; if (result) return { ...result, intentId: intent.id, params };
} }
@@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => {
if (segments.length < 2) return null; if (segments.length < 2) return null;
const results = []; const results = [];
for (const segment of segments) { for (const segment of segments) {
// eslint-disable-next-line no-await-in-loop
const r = await matchAndRun(segment); const r = await matchAndRun(segment);
if (r) results.push(r); 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. // WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized); const params = intent?.match(normalized);
if (intent && params) { if (intent && params) {
// eslint-disable-next-line no-await-in-loop
const result = await intent.run(params); const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed }; if (result) return { ...result, intentId: intent.id, params, routing: routed };
} }

View File

@@ -1,107 +0,0 @@
/**
* What the assistant knows about where the operator is standing.
*
* Every page offers every question. The assistant answers about orders, riders,
* hubs and the rest regardless of which screen is open, so hiding a question
* because you happen to be on Dispatch made it look narrower than it is. The
* page decides the ORDER — its own questions lead — not the membership.
*
* Every suggestion here must actually resolve against the intent catalog. A
* chip that comes back "I can't answer that yet" is worse than no chip.
*/
const CATALOG = [
'How many orders today?',
'How many orders are cancelled?',
'Morning batch orders',
'Revenue this week',
'How many riders are active?',
'How many clients do we have?',
'How many hubs?',
'How many vehicles?',
'Any open exceptions?',
'How many tripsheets are dispatched?',
'How many customers?',
'How many pricing rules?',
'Track consignment DM-CN-1',
'Assign a rider to an order',
"Repeat yesterday's run",
'Compare orders today vs yesterday',
'Status of hub Chennai',
'Find vehicle TN01AB1234',
'How many competitor branches are tracked?',
'How many carrier pricing entries do we have?',
];
const BY_ROUTE = [
[
// Matches both spellings: the board moved to /doormile/control-x, and
// /doormile/dispatch still resolves through the redirect in App.jsx, so a
// bookmark that lands there should still get this page's suggestions
// rather than the default set.
/^\/doormile\/(control-x|dispatch)/,
'Control X',
['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'],
],
[
/^\/doormile\/orders/,
'Orders',
['How many orders today?', 'Assign a rider to an order', 'How many orders are cancelled?', 'Revenue this week'],
],
[/^\/doormile\/deliveries/, 'Deliveries', ['How many orders are delivered?', 'Track consignment DM-CN-1', 'Morning batch orders']],
[/^\/doormile\/riders/, 'Riders', ['How many riders are active?', 'Where is Murali?']],
[/^\/doormile\/tenants/, 'Clients', ['How many clients do we have?', 'How many pricing rules?']],
[/^\/doormile\/customers/, 'Customers', ['How many customers?', 'Create a customer']],
[/^\/doormile\/pricing/, 'Pricing', ['How many pricing rules?', 'Revenue this week']],
[/^\/doormile\/hubs/, 'Hubs', ['How many hubs?', 'Status of hub Chennai', 'How many vehicles?']],
[/^\/doormile\/vehicles/, 'Vehicles', ['How many vehicles?', 'Find vehicle TN01AB1234', 'How many hubs?']],
[/^\/doormile\/tripsheets/, 'Tripsheets', ['How many tripsheets are dispatched?', 'How many consignments?']],
[/^\/doormile\/exceptions/, 'Exceptions', ['Any open exceptions?', 'How many consignments?']],
[
/^\/doormile\/competitive-intel/,
'Competitive Intel',
['How many competitor branches are tracked?', 'How many carrier pricing entries do we have?'],
],
[/^\/doormile\/reports/, 'Reports', ['Revenue this week', 'Compare orders today vs yesterday', 'How many orders today?']],
];
/** `{ label, suggestions }` for a route — its own questions first, then the rest. */
export const getPageContext = (pathname) => {
const hit = BY_ROUTE.find(([pattern]) => pattern.test(pathname || ''));
const label = hit ? hit[1] : 'Console';
const leading = hit ? hit[2] : [];
/* Deduplicated, leading questions first — one flat list means one place a
question can live. */
const suggestions = [...new Set([...leading, ...CATALOG])];
return { label, suggestions };
};
export const CHIP_LABELS = {
'How many orders today?': 'Orders today',
'How many orders are cancelled?': 'Cancelled',
'Morning batch orders': 'Morning batch',
'Revenue this week': 'Revenue',
'How many riders are active?': 'Active riders',
'How many clients do we have?': 'Clients',
'How many hubs?': 'Hubs',
'How many vehicles?': 'Vehicles',
'Any open exceptions?': 'Exceptions',
'How many tripsheets are dispatched?': 'Tripsheets',
'How many customers?': 'Customers',
'How many pricing rules?': 'Pricing rules',
'How many orders are delivered?': 'Delivered',
'Where is Murali?': 'Locate Murali',
'Create a customer': 'New customer',
'How many consignments?': 'Consignments',
'Track consignment DM-CN-1': 'Track DM-CN-1',
'Assign a rider to an order': 'Assign rider',
'Repeat yesterday\'s run': 'Repeat run',
'Compare orders today vs yesterday': 'Compare orders',
'Status of hub Chennai': 'Chennai hub',
'Find vehicle TN01AB1234': 'Find vehicle',
'How many competitor branches are tracked?': 'Competitor branches',
'How many carrier pricing entries do we have?': 'Carrier pricing'
};
export const ORDER_CREATED = '__orderCreated';
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';

View File

@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
if (shouldStop?.()) break; if (shouldStop?.()) break;
const row = candidates[i]; const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name }); 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); const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out); priced.push(out);
} }

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Building2, KeyRound, Mail, Shield, User } from 'lucide-react'; import { Building2, KeyRound, Mail, Shield } from 'lucide-react';
import { import {
Avatar, Button, Field, Grid, Input, PageHeader, SectionHeader, Stack, Surface, Avatar, Button, Field, Grid, Input, SectionHeader, Stack, Surface,
} from '@/components/ds'; } from '@/components/ds';
import { useProfile, useUpdatePassword } from '@/lib/doormileHooks'; import { useProfile, useUpdatePassword } from '@/lib/doormileHooks';
import { orDash } from '@/lib/doormileFormat'; import { orDash } from '@/lib/doormileFormat';

View File

@@ -2,8 +2,8 @@ import React, { useMemo, useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet, Ban, Bike,
Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle, Package, PackageSearch, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -16,7 +16,6 @@ import { OpenToast, messageOf } from '@/api/doormile/notify';
import { drainBookings } from '@/lib/assistant/scan'; import { drainBookings } from '@/lib/assistant/scan';
import { import {
useBooking, useAssignMilerToBooking, useRiderOptions, useBooking, useAssignMilerToBooking, useRiderOptions,
useCancelBooking, useBulkCancelBookings,
} from '@/lib/doormileHooks'; } from '@/lib/doormileHooks';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints'; import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
import { import {

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Building2 } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent, BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Users } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, SectionHeader, Stack, Surface, BreadcrumbSeparator, Button, Field, Input, PageHeader, SectionHeader, Stack, Surface,

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs'; 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 { 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, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds'; } from '@/components/ds';

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
ArrowRight, ArrowRight,
@@ -22,28 +22,21 @@ import {
Send, Send,
Shield, Shield,
Sparkles, Sparkles,
Table,
Upload, Upload,
Users, Users
Volume2,
VolumeX,
X
} from 'lucide-react'; } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { DOORMILE_WORDMARK_URL } from '@/assets/brand'; import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { OpenToast } from '@/components/third-party/OpenToast'; import { OpenToast } from '@/components/third-party/OpenToast';
import { answerQuestion, EXAMPLE_QUESTIONS, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents'; import { answerQuestion } from '@/lib/assistant/intents';
import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions';
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions'; import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions';
import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions'; import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions';
import { startCustomerFlow, answerStep } from '@/lib/assistant/customerFlow';
import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow'; import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow';
import { startBulkFlow, answerBulkStep } from '@/lib/assistant/bulkFlow'; import { answerAssignStep } from '@/lib/assistant/assignFlow';
import { startAssignFlow, answerAssignStep } from '@/lib/assistant/assignFlow';
import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions'; import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions';
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow'; 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 = { const LOGO_FILTER = {
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)', filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import dayjs from 'dayjs'; 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 { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks'; import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext'; import { useZone } from '@/lib/ZoneContext';

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Bike } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent, BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, Bike } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select, BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select,