diff --git a/src/components/assistant/CLAUDE.md b/src/components/assistant/CLAUDE.md
index 4df5950..a023b1f 100644
--- a/src/components/assistant/CLAUDE.md
+++ b/src/components/assistant/CLAUDE.md
@@ -36,7 +36,8 @@ An in-console Q&A assistant that answers operator questions about live data —
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
-- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
+- **Each page offers ONLY its own suggestions.** `getPageContext` returns the route's context list and nothing else — no global set is appended. Asked for directly: on Orders the strip should be Orders questions, and likewise everywhere. This REVERSES the previous rule ("every page offers every suggestion"), which had already been walked back once from all twenty-one questions to a thirteen-question global set because Orders was offering "How many tenants do we have?" above the composer. Note it was barely observable before the change: every context declares at least four suggestions and the panel slices to four, so the globals mostly sat just past the cut — the behaviour was an accident of counting, not a rule. It is structural now. Dropping a question from a page's list does not remove it from the assistant: it stays typeable, stays a follow-up, and still leads on its own page.
+- **A route missing from `ROUTES` falls back to `DEFAULT_CONTEXT`**, which is the generic operations set — and that is how a page ends up offering hub questions beside a table of bookings. `/doormile/bookings` did exactly that until it was given its own context. `/doormile/app-users` and `/doormile/competitive-intel` still fall back deliberately: the intent catalog has no questions about app users or competitor branches, and inventing chips that resolve to nothing would break the rule two lines down. Give a page its own context when real intents exist for it, not before.
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
diff --git a/src/components/assistant/DoormileAI.css b/src/components/assistant/DoormileAI.css
index 1219710..3b32575 100644
--- a/src/components/assistant/DoormileAI.css
+++ b/src/components/assistant/DoormileAI.css
@@ -925,50 +925,14 @@ body.dai-docked .astryx-layout-content {
padding-inline: 2px;
}
/* --------------------------------------------------------------------------
- Trigger (lives in the app TopNav)
+ The trigger's styles lived here and are gone with it.
+
+ `.dai-trigger` / `.dai-trigger-mark` styled DoormileAI/index.jsx, a trigger
+ button component that nothing rendered — AdminLayout has always declared
+ its own button in JSX with Tailwind classes. Component and CSS removed
+ together so neither is left looking like the thing that controls the
+ header button. To change that button, edit AdminLayout.
-------------------------------------------------------------------------- */
-.dai-trigger {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 40px;
- height: 40px;
- padding: 0;
- border-radius: 5px;
- border: 1px solid transparent;
- background: transparent;
- cursor: pointer;
- overflow: hidden;
- transition:
- background-color 140ms ease,
- border-color 140ms ease,
- transform 140ms ease;
-}
-/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
- 30px inside the 40px button. */
-.dai-trigger-mark {
- width: 30px;
- height: 30px;
- max-width: none;
- object-fit: contain;
- display: block;
- transform: scale(1.5);
-}
-.dai-trigger:hover {
- background: rgba(0, 0, 0, 0.05);
- border-color: rgba(0, 0, 0, 0.1);
-}
-.dai-trigger:active {
- transform: scale(0.94);
-}
-.dai-trigger:focus-visible {
- outline: 2px solid var(--dai-accent);
- outline-offset: 2px;
-}
-.dai-trigger[data-active='true'] {
- background: rgba(0, 0, 0, 0.07);
- border-color: rgba(0, 0, 0, 0.14);
-}
/* --------------------------------------------------------------------------
Responsive
-------------------------------------------------------------------------- */
@@ -1025,8 +989,7 @@ body.dai-docked .astryx-layout-content {
.dai-typing span,
.dai-shimmer,
.dai-suggestion,
- .dai-send,
- .dai-trigger {
+ .dai-send {
animation: none !important;
transition: none !important;
}
diff --git a/src/components/assistant/DoormileAI/AIFlowStep.jsx b/src/components/assistant/DoormileAI/AIFlowStep.jsx
index 6ccdc70..7b2199d 100644
--- a/src/components/assistant/DoormileAI/AIFlowStep.jsx
+++ b/src/components/assistant/DoormileAI/AIFlowStep.jsx
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
-import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@/components/ui/button';
import { Selector } from '@astryxdesign/core/Selector';
@@ -38,7 +37,7 @@ const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
return () => {
alive = false;
};
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [step.id]);
if (failed) {
diff --git a/src/components/assistant/DoormileAI/AIPanel.jsx b/src/components/assistant/DoormileAI/AIPanel.jsx
index ed27346..ee3c372 100644
--- a/src/components/assistant/DoormileAI/AIPanel.jsx
+++ b/src/components/assistant/DoormileAI/AIPanel.jsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
-import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
import {
LuChevronDown,
LuHistory,
@@ -19,7 +18,6 @@ import {
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
-import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { IconButton } from '@/components/ds';
import { OpenToast } from 'components/third-party/OpenToast';
@@ -36,7 +34,7 @@ import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assista
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
import { getPageContext, getFollowUps, toChips, ORDER_CREATED, ORDER_CREATED_ASSIGNED } from './pageContext';
-import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
+import { Spark, TypingIndicator } from './AIParts';
import AIWelcome, { SuggestionChips } from './AIWelcome';
import AIMessage from './AIMessage';
import AIComposer from './AIComposer';
diff --git a/src/components/assistant/DoormileAI/AIRowsStep.jsx b/src/components/assistant/DoormileAI/AIRowsStep.jsx
index c02916d..e8c4ec1 100644
--- a/src/components/assistant/DoormileAI/AIRowsStep.jsx
+++ b/src/components/assistant/DoormileAI/AIRowsStep.jsx
@@ -2,7 +2,6 @@ import { useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from '@astryxdesign/core/VStack';
-import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@/components/ui/button';
import { TextArea } from '@astryxdesign/core/TextArea';
diff --git a/src/components/assistant/DoormileAI/index.jsx b/src/components/assistant/DoormileAI/index.jsx
deleted file mode 100644
index 01c4a54..0000000
--- a/src/components/assistant/DoormileAI/index.jsx
+++ /dev/null
@@ -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 (
- <>
-
-
-
-
- >
- );
-};
-
-export default DoormileAITrigger;
diff --git a/src/components/assistant/DoormileAI/pageContext.jsx b/src/components/assistant/DoormileAI/pageContext.jsx
index 7dee9d8..f0c5f8e 100644
--- a/src/components/assistant/DoormileAI/pageContext.jsx
+++ b/src/components/assistant/DoormileAI/pageContext.jsx
@@ -66,6 +66,9 @@ export const CHIP_LABELS = {
'Total revenue this week': 'Revenue this week',
'How many customers do we have?': 'Customers',
'Assign a rider': 'Assign order',
+ 'How many tripsheets are dispatched?': 'Tripsheets',
+ 'How many open exceptions?': 'Open exceptions',
+ 'How many pricing rules are configured?': 'Pricing rules',
"Repeat yesterday's orders": 'Repeat yesterday'
};
@@ -167,6 +170,73 @@ const DISPATCH = {
]
};
+// Linehaul and middle-mile runs.
+const TRIPSHEETS = {
+ label: 'Tripsheets',
+ suggestions: [
+ { icon: LuLayers, text: 'How many tripsheets are dispatched?' },
+ { icon: LuTruck, text: 'How many vehicles are available?' },
+ { icon: LuBike, text: 'How many riders are active?' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: [{ icon: LuBuilding2, text: 'Current hub status' }]
+};
+
+// The register of things that went wrong.
+const EXCEPTIONS = {
+ label: 'Exceptions',
+ suggestions: [
+ { icon: LuCircleDot, text: 'How many open exceptions?' },
+ { icon: LuTimerOff, text: 'Which orders are delayed?' },
+ { icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: []
+};
+
+// Rate cards. Reached from the account menu rather than the bar, but it is a
+// real page and deserves its own questions like any other.
+const PRICING = {
+ label: 'Pricing',
+ suggestions: [
+ { icon: LuBanknote, text: 'How many pricing rules are configured?' },
+ { icon: LuBanknote, text: 'Total revenue today' },
+ { icon: LuUsers, text: 'How many tenants do we have?' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
+};
+
+// Bookings — what customers raised in the app, before it becomes an order.
+//
+// This page was not in ROUTES at all, so it fell through to DEFAULT_CONTEXT
+// and offered "How many riders are active?" and "Current hub status" beside a
+// table of B2C bookings. It is a main nav destination, so that was the most
+// visible instance of the problem this change fixes.
+//
+// No create actions: a booking here originates in the customer app, not in
+// this console, so "Create an order" would offer to make a different kind of
+// thing than the page shows.
+//
+// One honest limitation: these intents count ALL bookings, not only the
+// customer-app ones this page lists. They are the right questions to ask from
+// here and every one of them resolves, but the number will not match the row
+// count when the page is filtered to a source.
+const BOOKINGS = {
+ label: 'Bookings',
+ suggestions: [
+ { icon: LuPackage, text: 'How many orders today?' },
+ { icon: LuCircleDot, text: 'How many pending orders today?' },
+ { icon: LuTimerOff, text: 'Which orders are delayed?' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: [
+ { icon: LuPackage, text: 'How many delivered orders today?' },
+ { icon: LuPackage, text: 'How many cancelled orders today?' },
+ { icon: LuClock3, text: 'Orders today vs yesterday' }
+ ]
+};
+
const TENANTS = {
label: 'Tenants',
suggestions: [
@@ -283,6 +353,7 @@ const FOLLOW_UPS = {
const ROUTES = [
['/doormile/control-x', DISPATCH],
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
+ ['/doormile/bookings', BOOKINGS],
['/doormile/orders', ORDERS],
['/doormile/milers', RIDERS],
['/doormile/riders', RIDERS],
@@ -303,9 +374,19 @@ const ROUTES = [
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
}
],
- ['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
- ['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
- ['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
+ // These three were `{ ...DEFAULT_CONTEXT, label: '…' }` — a page-specific
+ // NAME over the generic operations questions, so Tripsheets offered "Active
+ // riders" and "Hub status" and nothing about tripsheets. The label made it
+ // look intentional.
+ //
+ // Each now leads with its own resource. The phrasings come from the intents'
+ // own documented examples (tripsheetStatus, exceptionStatus, pricingCount)
+ // and were checked against their `match` predicates before being added here,
+ // per the rule at the top of this file. None contains an order/booking word,
+ // so none is shadowed by the order intents that run earlier in the catalog.
+ ['/doormile/tripsheets', TRIPSHEETS],
+ ['/doormile/exceptions', EXCEPTIONS],
+ ['/doormile/pricing', PRICING]
];
// ---- every question, from every page ---------------------------------------
@@ -322,8 +403,9 @@ const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS
// Every question declared anywhere. Two jobs: it is the icon index behind
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
-// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
-// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
+// still renders with the right icon). It no longer feeds a global suggestion
+// pool — see the note below. Do not narrow it: a question dropped from here
+// loses its icon everywhere, including as a follow-up.
const EVERY_QUESTION = (() => {
const seen = new Set();
const out = [];
@@ -338,44 +420,38 @@ const EVERY_QUESTION = (() => {
})();
// ---------------------------------------------------------------------------
-// The questions that ride along on EVERY page.
+// There is no longer a set of questions that rides along on every page.
//
-// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
-// Orders page offered "How many tenants do we have?" and "Current hub status"
-// above the composer, questions an operator working orders is not asking. The
-// strip is a shortcut, and a shortcut that lists everything is a menu.
+// GLOBAL_TEXTS / GLOBAL_SUGGESTIONS lived here: a thirteen-question daily
+// operating picture appended to every context. It had already been narrowed
+// once, from all twenty-one, because the Orders page was offering "How many
+// tenants do we have?" above the composer. It is gone entirely now — a page
+// offers its own questions and nothing else.
//
-// What is left is the daily operating picture: what came in, what is stuck,
-// what got out, what it earned, and the three ways to put work into the system.
-//
-// Dropping a question from THIS list does not remove it from the assistant —
-// it stays typeable, it stays a follow-up, and it still leads on its own page,
-// because withGlobalQuestions puts each page's own suggestions first. Hubs
-// still opens on hub questions; they just no longer follow you to Orders.
+// EVERY_QUESTION above stays. It is still the icon index behind
+// QUESTION_BY_TEXT, so a follow-up naming a question that no longer appears
+// on any page strip still renders with the right icon.
// ---------------------------------------------------------------------------
-const GLOBAL_TEXTS = new Set([
- "Give me today's operations summary",
- 'How many orders today?',
- 'Which orders are delayed?',
- 'How many pending orders today?',
- 'How many delivered orders today?',
- 'How many cancelled orders today?',
- 'Morning Batch orders today',
- 'Orders today vs yesterday',
- 'Total revenue today',
- 'How many riders are active?',
- 'Create an order',
- REPEAT_TEXT,
- 'Create multiple orders'
-]);
-const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
-
-// The page's own suggestions lead; the global set follows, deduplicated.
-const withGlobalQuestions = (context) => {
+// A page offers its OWN questions, and only those.
+//
+// The global set no longer rides along. Asked for directly: on Orders the
+// strip should be Orders questions, and the same on every other page.
+//
+// Worth being precise about what changed, because it is easy to read this as
+// a no-op. It very nearly was: every context happens to declare at least four
+// own suggestions and the panel slices to four, so in practice the globals sat
+// just past the cut on most pages. That made the current behaviour an accident
+// of counting rather than a rule — declare a context with three suggestions
+// and a hub question would surface on it. Now it cannot, whatever the counts.
+//
+// Dropping a question from a page's list does not remove it from the
+// assistant. It stays typeable, it stays a follow-up, and it still leads on
+// its own page. The strip is a shortcut, not the index.
+const ownQuestionsOnly = (context) => {
const seen = new Set();
const suggestions = [];
- [...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
+ [...(context.suggestions || []), ...(context.more || [])].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
suggestions.push(q);
@@ -385,7 +461,7 @@ const withGlobalQuestions = (context) => {
export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
- return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
+ return ownQuestionsOnly(match ? match[1] : DEFAULT_CONTEXT);
};
// ---------------------------------------------------------------------------
diff --git a/src/components/assistant/assignActions.js b/src/components/assistant/assignActions.js
index c93fe8b..7f7c0e3 100644
--- a/src/components/assistant/assignActions.js
+++ b/src/components/assistant/assignActions.js
@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom.
- // eslint-disable-next-line no-restricted-syntax
+
for (const t of targets) {
try {
- // eslint-disable-next-line no-await-in-loop
+
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1;
assignedRiders.add(String(t.mileruserid));
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// drops back should feel one buzz, not ten.
let notified = 0;
if (lookup) {
- // eslint-disable-next-line no-restricted-syntax
+
for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) {
try {
- // eslint-disable-next-line no-await-in-loop
+
await notifyRider(rider.milerprofileid);
notified += 1;
} catch {
diff --git a/src/components/assistant/bulkFlow.js b/src/components/assistant/bulkFlow.js
index aebeb1b..d2ce0b6 100644
--- a/src/components/assistant/bulkFlow.js
+++ b/src/components/assistant/bulkFlow.js
@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
if (hasCoords(row)) {
located.push(row);
- // eslint-disable-next-line no-continue
+
continue;
}
const key = cacheKey(row);
if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) });
- // eslint-disable-next-line no-continue
+
continue;
}
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
- // eslint-disable-next-line no-await-in-loop
+
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = {
deliverylatitude: place?.geometry?.location?.lat?.(),
@@ -147,7 +147,7 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
// Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast.
- // eslint-disable-next-line no-await-in-loop
+
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
}
diff --git a/src/components/assistant/bulkOrderActions.js b/src/components/assistant/bulkOrderActions.js
index 5e36bf7..4aa0963 100644
--- a/src/components/assistant/bulkOrderActions.js
+++ b/src/components/assistant/bulkOrderActions.js
@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (String(row.finalprice ?? '') !== '') {
out.push(row);
- // eslint-disable-next-line no-continue
+
continue;
}
if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
- // eslint-disable-next-line no-continue
+
continue;
}
- // eslint-disable-next-line no-await-in-loop
+
const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' });
- // eslint-disable-next-line no-continue
+
continue;
}
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
@@ -209,7 +209,7 @@ export const executeCreateBulk = async (rows, shared) => {
const batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try {
- // eslint-disable-next-line no-await-in-loop
+
const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
status: 'error',
errorMessage: res.message || 'Rejected'
});
- // eslint-disable-next-line no-continue
+
continue;
}
diff --git a/src/components/assistant/customerFlow.js b/src/components/assistant/customerFlow.js
index aac1c7c..6307c59 100644
--- a/src/components/assistant/customerFlow.js
+++ b/src/components/assistant/customerFlow.js
@@ -103,7 +103,7 @@ export const advance = (flow) => {
const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) {
step += 1;
- // eslint-disable-next-line no-continue
+
continue;
}
return { flow: { ...flow, step }, ask: s.ask, done: false };
diff --git a/src/components/assistant/flowEngine.js b/src/components/assistant/flowEngine.js
index 5944584..09705b8 100644
--- a/src/components/assistant/flowEngine.js
+++ b/src/components/assistant/flowEngine.js
@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1;
- // eslint-disable-next-line no-continue
+
continue;
}
if (s.auto) {
- // eslint-disable-next-line no-await-in-loop
+
const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value);
step += 1;
- // eslint-disable-next-line no-continue
+
continue;
}
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
diff --git a/src/components/assistant/intents.js b/src/components/assistant/intents.js
index c1fd5de..67867f5 100644
--- a/src/components/assistant/intents.js
+++ b/src/components/assistant/intents.js
@@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => {
let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
- // eslint-disable-next-line no-await-in-loop
+
const next = await getBookingsPageCached(page);
lastPageFetched = page;
if (!next.rows.length) {
@@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => {
for (const intent of INTENTS) {
const params = intent.match(text);
if (!params) continue;
- // eslint-disable-next-line no-await-in-loop
+
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params };
}
@@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => {
if (segments.length < 2) return null;
const results = [];
for (const segment of segments) {
- // eslint-disable-next-line no-await-in-loop
+
const r = await matchAndRun(segment);
if (r) results.push(r);
}
@@ -2243,7 +2243,7 @@ export async function answerQuestion(text, context = {}) {
// WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized);
if (intent && params) {
- // eslint-disable-next-line no-await-in-loop
+
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed };
}
diff --git a/src/components/assistant/repeatRuns.js b/src/components/assistant/repeatRuns.js
index df41b48..bab3601 100644
--- a/src/components/assistant/repeatRuns.js
+++ b/src/components/assistant/repeatRuns.js
@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
if (shouldStop?.()) break;
const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
- // eslint-disable-next-line no-await-in-loop
+
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out);
}
diff --git a/src/components/doormile/HubLocationSelect.jsx b/src/components/doormile/HubLocationSelect.jsx
index aae71d5..1e22c85 100644
--- a/src/components/doormile/HubLocationSelect.jsx
+++ b/src/components/doormile/HubLocationSelect.jsx
@@ -1,5 +1,5 @@
import React, { useMemo, useState } from 'react';
-import { Building2, Check, ChevronDown, MapPin, Search, Sparkles } from 'lucide-react';
+import { Building2, Check, ChevronDown, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
diff --git a/src/components/doormile/assistant/AssistantPanel.jsx b/src/components/doormile/assistant/AssistantPanel.jsx
deleted file mode 100644
index 73dbbac..0000000
--- a/src/components/doormile/assistant/AssistantPanel.jsx
+++ /dev/null
@@ -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 (
-
- )}
-
- {/* 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 && (
-
- );
-}
-
-/** @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 (
- <>
-
-
-
- >
- );
-}
diff --git a/src/components/ds/ZoneSelector.jsx b/src/components/ds/ZoneSelector.jsx
index a6acd48..37d6bb7 100644
--- a/src/components/ds/ZoneSelector.jsx
+++ b/src/components/ds/ZoneSelector.jsx
@@ -7,7 +7,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
diff --git a/src/components/nearle_components/AddressAutocomplete.jsx b/src/components/nearle_components/AddressAutocomplete.jsx
index 4112344..0db978b 100644
--- a/src/components/nearle_components/AddressAutocomplete.jsx
+++ b/src/components/nearle_components/AddressAutocomplete.jsx
@@ -53,7 +53,7 @@ const AddressAutocomplete = ({
useEffect(() => {
if (value !== undefined && value !== inputValue) setInputValue(value || '');
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [value]);
const biasKey = `${bias?.lat ?? ''},${bias?.lng ?? ''}`;
@@ -84,7 +84,7 @@ const AddressAutocomplete = ({
}
setLoading(true);
fetchPredictions(inputValue);
- // eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [inputValue, biasKey]);
// Close the list on an outside click.
diff --git a/src/components/nearle_components/DebounceSearchBar.jsx b/src/components/nearle_components/DebounceSearchBar.jsx
index 762695f..5496d0a 100644
--- a/src/components/nearle_components/DebounceSearchBar.jsx
+++ b/src/components/nearle_components/DebounceSearchBar.jsx
@@ -1,4 +1,4 @@
-/* eslint-disable react/prop-types */
+
import React, { useEffect, useRef } from 'react';
import { useDebounce } from 'use-debounce';
diff --git a/src/layouts/AdminLayout.jsx b/src/layouts/AdminLayout.jsx
index 4ca2d41..1600d95 100644
--- a/src/layouts/AdminLayout.jsx
+++ b/src/layouts/AdminLayout.jsx
@@ -2,12 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
- Activity, Bell, Bike, Building2, Car, ChevronDown, Coins, FileSpreadsheet,
+ Activity, Bell, Bike, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, IndianRupee, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, Users, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
-import { DOORMILE_MARK_URL, DOORMILE_WORDMARK_URL } from '@/assets/brand';
+import { DOORMILE_MARK_URL } from '@/assets/brand';
import { Avatar } from '@/components/ds/Avatar';
import { Badge } from '@/components/ui/badge';
import {
@@ -416,30 +416,48 @@ export default function AdminLayout() {
aria-label={assistantOpen ? 'Close MileTruth' : 'Open MileTruth'}
aria-expanded={assistantOpen}
className={cn(
- 'grid h-8 w-8 place-items-center rounded-lg transition-colors cursor-pointer',
+ 'grid h-8 w-8 place-items-center rounded-lg transition-all cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
- assistantOpen ? 'bg-brand-tint text-brand' : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1'
+ // A RING, not a background fill.
+ //
+ // The fill was what capped the mark's size: it renders
+ // behind the image, so a larger mark covered it and the
+ // control looked dead on hover. That is why the icon sat at
+ // 20px, then 24px — each bump ate the only affordance the
+ // button had.
+ //
+ // A ring draws at the button's edge instead, so it stays
+ // visible whatever the mark's size, and the icon is free to
+ // be as large as the 32px box allows. The tint is kept
+ // underneath for the open state, where it still shows
+ // around the mark's transparent corners.
+ assistantOpen
+ ? 'bg-brand-tint text-brand ring-2 ring-brand/30'
+ : 'text-ink-3 hover:bg-surface-sunken hover:text-ink-1 hover:ring-2 hover:ring-brand/20'
)}
>
- {/* 24px inside a 32px button.
+ {/* 28px, filling the 32px button.
- Started at `h-9 w-9 max-w-none` — a 36px image in a 32px
- button, so the mark overflowed its own control by 2px on
- every side. That broke the row's alignment against the
- bell and avatar AND covered the button completely, leaving
- `hover:bg-surface-sunken` nowhere to render, so the control
- looked dead on hover while its neighbours lit up.
+ The size history is worth keeping, because each step was
+ forced by the hover treatment rather than by taste. It
+ began at `h-9 w-9 max-w-none` — 36px inside a 32px button,
+ overflowing its own control by 2px on every side, which
+ broke the row's alignment against the bell and avatar and
+ covered the button so the hover fill had nowhere to show.
+ 20px fixed both and read as too faint. 24px was as far as
+ it could go while a background fill was still the
+ affordance.
- Dropped to 20px to fix both, which read as too faint next
- to the bell. 24px is the ceiling that keeps the fix: it
- leaves 4px of button on every side, which is what the
- hover tint needs to be visible as a ring around the mark.
- Anything larger and the tint disappears again.
+ Moving that affordance to a ring (see the button above)
+ removed the cap, so this is sized for legibility rather
+ than around a constraint: 28px in a 32px box, and
+ comfortably larger than the Bell's 16px — which is right,
+ because a brand mark carrying its own circular frame reads
+ smaller than its box where a stroked icon reads full-size.
- Bigger than the Bell's 16px on purpose — this is a brand
- mark carrying its own circular frame, so it reads smaller
- than its box, where a stroked icon reads full-size. */}
-
+ `object-contain` with no `max-w-none`, so it can never
+ overflow the button again however the asset changes. */}
+
)}
diff --git a/src/lib/assistant/CLAUDE.md b/src/lib/assistant/CLAUDE.md
index 59230eb..6c561a1 100644
--- a/src/lib/assistant/CLAUDE.md
+++ b/src/lib/assistant/CLAUDE.md
@@ -37,7 +37,8 @@ An in-console Q&A assistant that answers operator questions about live data —
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
-- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
+- **Each page offers ONLY its own suggestions.** `getPageContext` returns the route's context list and nothing else — no global set is appended. Asked for directly: on Orders the strip should be Orders questions, and likewise everywhere. This REVERSES the previous rule ("every page offers every suggestion"), which had already been walked back once from all twenty-one questions to a thirteen-question global set because Orders was offering "How many tenants do we have?" above the composer. Note it was barely observable before the change: every context declares at least four suggestions and the panel slices to four, so the globals mostly sat just past the cut — the behaviour was an accident of counting, not a rule. It is structural now. Dropping a question from a page's list does not remove it from the assistant: it stays typeable, stays a follow-up, and still leads on its own page.
+- **A route missing from `ROUTES` falls back to `DEFAULT_CONTEXT`**, which is the generic operations set — and that is how a page ends up offering hub questions beside a table of bookings. `/doormile/bookings` did exactly that until it was given its own context. `/doormile/app-users` and `/doormile/competitive-intel` still fall back deliberately: the intent catalog has no questions about app users or competitor branches, and inventing chips that resolve to nothing would break the rule two lines down. Give a page its own context when real intents exist for it, not before.
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
diff --git a/src/lib/assistant/DoormileAI.css b/src/lib/assistant/DoormileAI.css
deleted file mode 100644
index b1be3a6..0000000
--- a/src/lib/assistant/DoormileAI.css
+++ /dev/null
@@ -1,1204 +0,0 @@
-/* ==========================================================================
- Doormile AI — Operations Copilot
- --------------------------------------------------------------------------
- Styling for the right-side slide-over assistant. Follows the same
- convention as OrdersRedesign.css / Dispatch.css (a page-scoped stylesheet
- imported by its own component) rather than inline styles, so the panel's
- hover / focus / media-query / reduced-motion states are all expressible.
-
- Every value below resolves to a design token (`--color-*`, `--spacing-*`,
- `--radius-*`) or to a local `--dai-*` token defined once here. The local
- tokens exist because the AI surface needs a few values the shared DT set
- doesn't carry (the AI accent, the scrim, the panel elevation).
- ========================================================================== */
-
-/* ---------------------------------------------------------------------------
- Dock geometry.
-
- These three live on :root, not on .dai-root, because the LAYOUT has to read
- them: the assistant is docked into the page rather than floated over it, so
- the app's content container shifts by exactly the panel's width and animates
- on the same curve. A token only .dai-root can see cannot do that.
-
- All three are --dai- prefixed. :root is shared with Dispatch.css and
- globalPolish.css, and an unprefixed name there is decided by bundle order.
- --------------------------------------------------------------------------- */
-:root {
- /* 30vw, NOT 30%.
-
- The two consumers resolve a percentage against different boxes: the panel
- is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
- `padding-right: 30%` on the content container is 30% of ITS containing
- block — which is narrower by the side nav. At 1440px that is 432px of
- panel against 410px of reserved strip, and the page slides 22px underneath
- the panel. A viewport unit resolves the same for both.
-
- max() rather than a separate min-width for the same reason: a min-width
- that only the panel knows about would reintroduce the mismatch on narrow
- screens. */
- /* Default = the NORMAL step. AIPanel overwrites this property on the
- document element when the operator toggles the width, so the panel and
- the page's reserved strip always read the same value. */
- --dai-dock-width: max(340px, 30vw);
- --dai-duration: 240ms;
- --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
-}
-
-.dai-root {
- /* Brand accent — resolves to the app's accent token, which is black
- (root CLAUDE.md §6.2: "the brand is black", one accent only). Send
- button, active states and the trigger all read from this single
- variable, so switching the assistant to Doormile red is a one-line
- change here rather than a hunt through the components. */
- --dai-accent: var(--color-accent, #0f172a);
- --dai-accent-contrast: #ffffff;
- /* Focus ring, tinted with the accent rather than a flat grey wash. */
- --dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
-
- /* AI identity accent — used ONLY on the spark/orb marks so the assistant
- reads as an AI surface without turning the panel into a purple product. */
- --dai-ai-from: #6366f1;
- --dai-ai-to: #8b5cf6;
- --dai-ai-glow: rgba(99, 102, 241, 0.18);
-
- --dai-text: #0f172a;
- --dai-text-secondary: #64748b;
- --dai-text-muted: #94a3b8;
- --dai-surface: #ffffff;
- --dai-surface-alt: #f8fafc;
- --dai-surface-hover: #f1f5f9;
- --dai-border: rgba(15, 23, 42, 0.08);
- --dai-border-strong: rgba(15, 23, 42, 0.14);
- --dai-scrim: rgba(15, 23, 42, 0.08);
- --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
- --dai-live: #10b981;
-
- --dai-panel-width: 428px;
- --dai-inset: 12px;
- --dai-duration: 240ms;
- --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
-}
-/* --------------------------------------------------------------------------
- Scrim — deliberately light. The dashboard underneath must stay readable;
- this is a layering cue, not a modal blackout.
- -------------------------------------------------------------------------- */
-/* The scrim exists for the MOBILE presentation only (see the media query at
- the end of this block). On desktop the panel is docked into the layout and
- dimming the page behind it would be a lie — the page is still live, still
- scrollable, and still the thing the operator is working on. Dimming is also
- the single strongest cue that something is modal, which is exactly the
- impression this panel should not give. */
-.dai-scrim {
- display: none;
- position: fixed;
- inset: 0;
- z-index: 1300;
- background: var(--dai-scrim);
- opacity: 0;
- transition: opacity var(--dai-duration) var(--dai-ease);
-}
-.dai-scrim[data-open='true'] {
- opacity: 1;
-}
-/* --------------------------------------------------------------------------
- Panel
- -------------------------------------------------------------------------- */
-/* DOCKED, not floating.
-
- It used to be an inset card: 12px off every edge, fully rounded, drop
- shadowed, over a scrim — every cue of a popup. It now sits flush in the
- right 30% of the workspace, starting under the app header and running to the
- bottom of the window, with a single hairline on its left edge. The page does
- not move underneath it; it makes room for it.
-
- `top` reads Astryx's own measured header height so the dock lines up with
- the bottom of the top nav rather than guessing a number.
-
- No radius and no drop shadow: both are what make a surface read as floating
- ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
- seam has depth without the panel detaching. */
-.dai-panel {
- position: fixed;
- /* Inset card geometry matching the reference design:
- Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */
- top: calc(var(--dai-dock-top, 57px) + 14px);
- right: 16px;
- bottom: 14px;
- z-index: 1200;
- width: calc(var(--dai-dock-width) - 24px);
- display: flex;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
- background: #ffffff;
- border: 1px solid #e2e8f0;
- border-radius: 20px;
- box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04);
- transform: translateX(calc(100% + 32px));
- visibility: hidden;
- transition:
- transform var(--dai-duration) var(--dai-ease),
- visibility 0s linear var(--dai-duration);
-}
-.dai-panel[data-open='true'] {
- transform: translateX(0);
- visibility: visible;
- transition:
- transform var(--dai-duration) var(--dai-ease),
- visibility 0s;
-}
-
-/* ---- The page makes room -------------------------------------------------
- `.dai-docked` is set on while the panel is open. Padding rather than
- width/margin: the content container is a flex child inside AppShell's own
- height:fill chain, and changing its width there fights that chain, whereas
- padding leaves the box model alone and simply reserves the strip.
-
- The transition sits on the container unconditionally so the page slides back
- when the panel closes too — a rule that only exists while `.dai-docked` is
- applied cannot animate its own removal. */
-.dai-page,
-.astryx-layout-content {
- transition: padding-right var(--dai-duration) var(--dai-ease);
-}
-body.dai-docked .dai-page,
-body.dai-docked .astryx-layout-content {
- /* Reserves the dock width plus a generous 20px gutter so the page content
- (tables, cards) never collides with the sidebar and stays distinct. */
- padding-right: calc(var(--dai-dock-width) + 16px);
-}
-
-/* ---- Mobile: there is no 30% worth having -------------------------------
- 30% of a phone is ~120px. Below the breakpoint the panel becomes what it
- used to be everywhere — a full-width overlay with a scrim — and the page
- stops reserving a strip it cannot afford. */
-@media (max-width: 900px) {
- .dai-panel {
- top: 0;
- right: 0;
- bottom: 0;
- width: 100%;
- z-index: 1301;
- border: none;
- border-radius: 0;
- box-shadow: var(--dai-shadow);
- transform: translateX(100%);
- }
- .dai-scrim {
- display: block;
- }
- .dai-scrim[data-open='true'] {
- opacity: 1;
- }
- body.dai-docked .dai-page,
- body.dai-docked .astryx-layout-content {
- padding-right: 0;
- }
-}
-.dai-panel:focus {
- outline: none;
-}
-/* --------------------------------------------------------------------------
- Header
- -------------------------------------------------------------------------- */
-.dai-root .dai-header {
- flex: 0 0 auto;
- padding: 14px 16px;
- border-bottom: 1px solid #f1f5f9;
- background: #ffffff;
-}
-.dai-root .dai-title {
- font-size: 16px;
- font-weight: 700;
- line-height: 1.2;
- letter-spacing: -0.01em;
- color: #0f172a;
-}
-.dai-root .dai-subtitle {
- font-size: 12px;
- line-height: 1.3;
- color: #64748b;
- font-weight: 500;
-}
-/* The AI mark. A soft gradient orb — not a robot face. */
-.dai-root .dai-spark {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- border-radius: 10px;
- background: #fff1f2;
- border: 1px solid #ffe4e6;
- overflow: hidden;
-}
-/* The mark's own canvas is only 66.8% content — a third of every edge is
- transparent padding, measured off the PNG's alpha bounding box (x and y both
- 170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore
- rendered a D two-thirds the size the box implied, which is exactly why it
- read as too small. 150% cancels that padding so the D fills its box edge to
- edge; the overflow:hidden above clips only transparent pixels. */
-.dai-root .dai-spark img {
- width: 150%;
- height: 150%;
- max-width: none;
- object-fit: contain;
- display: block;
-}
-.dai-root .dai-spark[data-size='sm'] {
- width: 30px;
- height: 30px;
-}
-.dai-root .dai-spark[data-size='md'] {
- width: 40px;
- height: 40px;
-}
-.dai-root .dai-spark[data-size='lg'] {
- width: 56px;
- height: 56px;
-}
-/* Live indicator — subtle, not a large pill. */
-.dai-root .dai-live {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 3px 8px;
- border-radius: 999px;
- font-size: 11px;
- font-weight: 550;
- color: #047857;
- background: rgba(16, 185, 129, 0.08);
- white-space: nowrap;
-}
-.dai-root .dai-live-dot {
- width: 6px;
- height: 6px;
- border-radius: 999px;
- background: var(--dai-live);
- animation: dai-pulse 2.4s ease-in-out infinite;
-}
-@keyframes dai-pulse {
- 0%,
- 100% {
- opacity: 1;
- transform: scale(1);
- }
- 50% {
- opacity: 0.55;
- transform: scale(0.85);
- }
-}
-/* Page-context strip — "Orders · Today · All locations" */
-.dai-root .dai-context {
- display: none;
-}
-/* --------------------------------------------------------------------------
- Welcome state
- -------------------------------------------------------------------------- */
-.dai-root .dai-welcome {
- padding: 22px 14px 8px;
-}
-.dai-root .dai-welcome-greeting {
- font-size: 17px;
- font-weight: 650;
- letter-spacing: -0.01em;
- color: var(--dai-text);
-}
-.dai-root .dai-welcome-lead {
- font-size: 13.5px;
- line-height: 1.5;
- color: var(--dai-text-secondary);
-}
-.dai-root .dai-welcome-note {
- font-size: 12px;
- line-height: 1.5;
- color: var(--dai-text-muted);
-}
-.dai-root .dai-section-label {
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--dai-text-muted);
-}
-/* --------------------------------------------------------------------------
- Suggestion cards
- -------------------------------------------------------------------------- */
-/* The suggestions strip sits between the conversation and the composer, so it
- needs the composer's horizontal rhythm and a hairline to separate it from
- the thread scrolling above it. */
-.dai-root .dai-suggestions-bar {
- /* Shrinkable, with a hard cap.
-
- At the normal 25% width the full twenty chips still stack to several rows.
- As `flex: 0 0 auto` that block refuses to shrink, so on a short laptop it
- pushes the composer off the bottom of the panel — the input disappears and
- the assistant cannot be used at all. The cap is a safety valve, not a
- layout choice: it only engages when the strip would otherwise cost more
- than a third of the panel, and it never applies once a thread exists,
- because the strip is four chips by then. */
- flex: 0 1 auto;
- min-height: 0;
- max-height: 34vh;
- overflow-y: auto;
- padding: 10px 14px 8px;
- border-top: 1px solid var(--dai-border);
- background: var(--dai-surface);
-}
-
-/* Follow-up mode: a handful of chips, so the strip is FIXED — it never shrinks
- and never scrolls.
-
- The cap and `flex-shrink: 1` above exist for the cold panel's twenty chips.
- Once a conversation starts the strip is three or four, and leaving it
- shrinkable meant a long thread squeezed it: the flex row gave the space to
- the conversation, `overflow-y: auto` quietly hid the remainder, and the
- follow-ups the operator was meant to act on disappeared below a fold nobody
- could see. Few chips must always be fully visible. */
-.dai-root .dai-suggestions-bar[data-compact='true'] {
- flex: 0 0 auto;
- max-height: none;
- overflow: visible;
-}
-
-/* ---- Suggested questions: chips ------------------------------------------
- They wrap, they never scroll. A horizontal scroller can always leave a chip
- half-visible at the edge, and a half-visible button is a bug no amount of
- fade masking fixes. */
-.dai-root .dai-suggestions-bar .dai-section-label {
- display: none;
-}
-.dai-root .dai-suggestions {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 8px;
- align-items: flex-start;
-}
-
-.dai-suggestion {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- max-width: 100%;
- padding: 8px 18px;
- text-align: left;
- font: inherit;
- font-size: 13px;
- line-height: 1.4;
- font-weight: 500;
- color: #334155;
- background: #ffffff;
- border: 1px solid #e2e8f0;
- border-radius: 9999px;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
- cursor: pointer;
- transition: all 140ms ease;
-}
-.dai-suggestion:hover {
- background: #f8fafc;
- border-color: #cbd5e1;
- color: #0f172a;
- transform: translateY(-1px);
- box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06);
-}
-.dai-suggestion:active {
- transform: translateY(0);
-}
-.dai-suggestion:focus-visible {
- outline: 2px solid #0f172a;
- outline-offset: 2px;
-}
-.dai-root .dai-suggestion-icon {
- flex: 0 0 auto;
- color: #64748b;
- transition: color 140ms ease;
-}
-.dai-suggestion:hover .dai-suggestion-icon {
- color: #0f172a;
-}
-.dai-root .dai-suggestion-text {
- min-width: 0;
-}
-/* Plain text link-button ("View more", "Sources") */
-.dai-link {
- font: inherit;
- font-size: 12px;
- color: var(--dai-text-secondary);
- background: none;
- border: none;
- padding: 2px 0;
- cursor: pointer;
- align-self: flex-start;
-}
-.dai-link:hover {
- color: var(--dai-text);
- text-decoration: underline;
-}
-.dai-link:focus-visible {
- outline: 2px solid var(--dai-accent);
- outline-offset: 2px;
- border-radius: 5px;
-}
-/* --------------------------------------------------------------------------
- Conversation
- -------------------------------------------------------------------------- */
-/* --------------------------------------------------------------------------
- Scroll region
- -------------------------------------------------------------------------- */
-.dai-scroll {
- flex: 1 1 auto;
- min-height: 0;
- overflow-y: auto;
- /* Never scroll sideways. A single wide child (the create form's name row)
- used to push the whole message area horizontally. */
- overflow-x: hidden;
- overscroll-behavior: contain;
- scrollbar-width: thin;
- scrollbar-color: var(--dai-border-strong) transparent;
-}
-
-.dai-scroll::-webkit-scrollbar {
- width: 8px;
-}
-
-.dai-scroll::-webkit-scrollbar-thumb {
- background: var(--dai-border-strong);
- border-radius: 999px;
- border: 2px solid transparent;
- background-clip: content-box;
-}
-
-.dai-scroll::-webkit-scrollbar-track {
- background: transparent;
-}
-
-.dai-scroll-wrap {
- position: relative;
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
-}
-
-.dai-root .dai-thread {
- padding: 16px 14px 8px;
-}
-
-/* Only the newest turn animates in. Applying this to every .dai-msg meant
- opening a panel with a restored thread started 50 simultaneous opacity +
- transform animations, which is a visible hitch before the first scroll. The
- base state IS the animation's end state, so a turn that stops being last
- simply keeps its finished appearance. */
-.dai-root .dai-thread > .dai-msg:last-child {
- animation: dai-enter 220ms var(--dai-ease) both;
-}
-
-@keyframes dai-enter {
- from {
- opacity: 0;
- transform: translateY(4px);
- }
- to {
- opacity: 1;
- transform: none;
- }
-}
-
-.dai-root .dai-msg-user {
- max-width: 82%;
- margin-left: auto;
- padding: 8px 12px;
- border-radius: 5px;
- border-bottom-right-radius: 5px;
- font-size: 13.5px;
- line-height: 1.45;
- color: var(--dai-accent-contrast);
- background: var(--dai-accent);
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-/* Assistant — no bubble. Text sits on the panel surface. */
-.dai-root .dai-msg-ai {
- font-size: 13.5px;
- line-height: 1.5;
- color: var(--dai-text);
- overflow-wrap: anywhere;
-}
-.dai-root .dai-msg-ai-detail {
- font-size: 12.5px;
- line-height: 1.55;
- color: var(--dai-text-secondary);
- white-space: pre-line;
-}
-.dai-root .dai-msg-name {
- font-size: 11.5px;
- font-weight: 600;
- color: var(--dai-text-secondary);
-}
-.dai-root .dai-msg-footer {
- font-size: 11px;
- color: var(--dai-text-muted);
-}
-/* Copy button — only revealed on hover of the message row. */
-.dai-root .dai-msg-actions {
- opacity: 0;
- transition: opacity 140ms ease;
-}
-.dai-root .dai-msg-row:hover .dai-msg-actions,
-.dai-root .dai-msg-row:focus-within .dai-msg-actions {
- opacity: 1;
-}
-/* --------------------------------------------------------------------------
- Structured metrics
- -------------------------------------------------------------------------- */
-.dai-root .dai-stat-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 6px;
- width: 100%;
-}
-.dai-root .dai-stat {
- padding: 9px 10px;
- border: 1px solid var(--dai-border);
- border-radius: 5px;
- background: var(--dai-surface-alt);
-}
-.dai-root .dai-stat-value {
- font-size: 19px;
- font-weight: 650;
- line-height: 1.1;
- letter-spacing: -0.02em;
- font-variant-numeric: tabular-nums;
-}
-.dai-root .dai-stat-label {
- font-size: 11px;
- color: var(--dai-text-muted);
-}
-/* Headline metric — the "primary number is large" rule. */
-.dai-root .dai-metric-value {
- font-size: 28px;
- font-weight: 680;
- line-height: 1.05;
- letter-spacing: -0.025em;
- color: var(--dai-text);
- font-variant-numeric: tabular-nums;
-}
-.dai-root .dai-metric-label {
- font-size: 12px;
- color: var(--dai-text-secondary);
-}
-/* --------------------------------------------------------------------------
- Typing / loading
- -------------------------------------------------------------------------- */
-.dai-root .dai-typing {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- height: 18px;
-}
-.dai-root .dai-typing span {
- width: 5px;
- height: 5px;
- border-radius: 999px;
- background: var(--dai-text-muted);
- animation: dai-bounce 1.3s ease-in-out infinite;
-}
-.dai-root .dai-typing span:nth-child(2) {
- animation-delay: 0.16s;
-}
-.dai-root .dai-typing span:nth-child(3) {
- animation-delay: 0.32s;
-}
-@keyframes dai-bounce {
- 0%,
- 70%,
- 100% {
- opacity: 0.3;
- transform: translateY(0);
- }
- 35% {
- opacity: 0.9;
- transform: translateY(-3px);
- }
-}
-.dai-root .dai-shimmer {
- height: 9px;
- border-radius: 999px;
- background: linear-gradient(90deg, var(--dai-surface-hover) 25%, #e9eef5 37%, var(--dai-surface-hover) 63%);
- background-size: 400% 100%;
- animation: dai-shimmer 1.5s ease infinite;
-}
-@keyframes dai-shimmer {
- from {
- background-position: 100% 50%;
- }
- to {
- background-position: 0 50%;
- }
-}
-/* --------------------------------------------------------------------------
- States (error / empty)
- -------------------------------------------------------------------------- */
-.dai-root .dai-state {
- padding: 12px;
- border: 1px solid var(--dai-border);
- border-radius: 5px;
- background: var(--dai-surface-alt);
-}
-.dai-root .dai-state-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--dai-text);
-}
-.dai-root .dai-state-body {
- font-size: 12.5px;
- line-height: 1.5;
- color: var(--dai-text-secondary);
-}
-.dai-root .dai-state-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 26px;
- height: 26px;
- border-radius: 5px;
- flex: 0 0 auto;
-}
-/* --------------------------------------------------------------------------
- Jump-to-latest
- -------------------------------------------------------------------------- */
-.dai-jump {
- position: absolute;
- left: 50%;
- bottom: 8px;
- transform: translateX(-50%);
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 5px 11px;
- font: inherit;
- font-size: 12px;
- color: var(--dai-text);
- background: var(--dai-surface);
- border: 1px solid var(--dai-border-strong);
- border-radius: 999px;
- box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1);
- cursor: pointer;
- z-index: 2;
-}
-.dai-jump:hover {
- background: var(--dai-surface-alt);
-}
-.dai-jump:focus-visible {
- outline: 2px solid var(--dai-accent);
- outline-offset: 2px;
-}
-/* --------------------------------------------------------------------------
- Composer
- -------------------------------------------------------------------------- */
-.dai-root .dai-composer-wrap {
- flex: 0 0 auto;
- padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
- border-top: 1px solid #f1f5f9;
- background: #ffffff;
-}
-.dai-root .dai-composer {
- border: 1px solid #e2e8f0;
- border-radius: 9999px;
- background: #f8fafc;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
- padding: 6px 6px 6px 16px;
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- transition:
- border-color 140ms ease,
- box-shadow 140ms ease,
- background-color 140ms ease;
-}
-.dai-root .dai-composer[data-focused='true'] {
- background: #ffffff;
- border-color: #cbd5e1;
- box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.05);
-}
-
-/* --------------------------------------------------------------------------
- Conversation history.
-
- A list of rows, not cards: these are destinations, and dense rows scan
- faster than boxes when you are looking for one you remember.
- -------------------------------------------------------------------------- */
-.dai-root .dai-history {
- padding: 4px 0;
-}
-.dai-root .dai-history-row {
- width: 100%;
- border-radius: 10px;
- padding-right: 4px;
- transition: background-color 140ms ease;
-}
-.dai-root .dai-history-row:hover {
- background: var(--dai-surface-alt);
-}
-.dai-root .dai-history-open {
- flex: 1 1 auto;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 2px;
- align-items: flex-start;
- text-align: left;
- padding: 9px 10px;
- border: none;
- background: transparent;
- font: inherit;
- cursor: pointer;
- border-radius: 10px;
-}
-.dai-root .dai-history-open:focus-visible {
- outline: 2px solid var(--dai-accent);
- outline-offset: -2px;
-}
-.dai-root .dai-history-title {
- font-size: 13px;
- font-weight: 500;
- color: var(--dai-text);
- display: block;
- width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.dai-root .dai-history-meta {
- font-size: 11px;
- color: var(--dai-text-muted);
-}
-.dai-root .dai-history-empty-title {
- font-size: 13.5px;
- font-weight: 600;
- color: var(--dai-text);
-}
-.dai-root .dai-history-empty {
- font-size: 12.5px;
- line-height: 1.5;
- color: var(--dai-text-muted);
-}
-
-/* Microphone.
-
- Astryx's smallest dictation button is 28px, two short of the send button it
- sits beside — a visible step between two controls on the same row. Pinned to
- match, with the glyph scaled through font-size because the icon inside is
- sized in em units (the same reason the side nav icons needed font-size
- rather than width). */
-.dai-root .dai-mic {
- width: 30px;
- height: 30px;
- min-width: 30px;
- border-radius: 50%;
- font-size: 16px;
-}
-.dai-root .dai-mic svg {
- width: 1em;
- height: 1em;
-}
-
-/* --------------------------------------------------------------------------
- Send button.
-
- This had NO styles at all — the class was referenced once, in a
- reduced-motion reset, and nowhere else. It rendered as a bare 16px arrow
- glyph on a transparent background with square corners: not a button, just an
- icon sitting next to the microphone. Verified in the browser before fixing.
- -------------------------------------------------------------------------- */
-.dai-root .dai-send {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- width: 32px;
- height: 32px;
- padding: 0;
- border: none;
- border-radius: 50%;
- background: #2563eb;
- color: #ffffff;
- cursor: pointer;
- transition:
- background-color 140ms ease,
- opacity 140ms ease,
- transform 140ms ease;
-}
-.dai-root .dai-send:hover:not(:disabled) {
- background: #1d4ed8;
- transform: scale(1.05);
-}
-.dai-root .dai-send:active:not(:disabled) {
- transform: scale(0.96);
-}
-.dai-root .dai-send:focus-visible {
- outline: 2px solid #2563eb;
- outline-offset: 2px;
-}
-/* Disabled is the resting state until something is typed, so it has to read as
- "not yet", not as "broken" — a filled grey circle, same shape and weight. */
-.dai-root .dai-send:disabled {
- background: #f1f5f9;
- color: #94a3b8;
- cursor: default;
-}
-.dai-root .dai-composer textarea {
- display: block;
- flex: 1 1 auto;
- width: 100%;
- min-width: 0;
- border: none;
- outline: none;
- resize: none;
- padding: 4px 0;
- margin: 0;
- font: inherit;
- font-size: 13.5px;
- line-height: 1.45;
- color: var(--dai-text);
- background: transparent;
- max-height: 108px; /* ~5 lines */
- overflow-y: auto;
-}
-.dai-root .dai-composer textarea::placeholder {
- color: var(--dai-text-muted);
-}
-.dai-root .dai-hint {
- font-size: 10.5px;
- line-height: 1.3;
- color: var(--dai-text-muted);
- padding-inline: 2px;
-}
-/* --------------------------------------------------------------------------
- Trigger (lives in the app TopNav)
- -------------------------------------------------------------------------- */
-.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
- -------------------------------------------------------------------------- */
-@media (max-width: 1279px) {
- .dai-root {
- --dai-panel-width: 396px;
- }
-}
-@media (max-width: 767px) {
- .dai-root {
- --dai-inset: 0px;
- }
- .dai-panel {
- width: 100vw;
- max-width: 100vw;
- border-radius: 0;
- border: none;
- }
-}
-/* --------------------------------------------------------------------------
- Reduced motion — no slide, no pulse, no shimmer. Opacity only.
- -------------------------------------------------------------------------- */
-@media (prefers-reduced-motion: reduce) {
- .dai-panel,
- .dai-scrim {
- transition: opacity 1ms linear;
- }
- .dai-panel {
- transform: none;
- }
- .dai-msg,
- .dai-live-dot,
- .dai-typing span,
- .dai-shimmer,
- .dai-suggestion,
- .dai-send,
- .dai-trigger {
- animation: none !important;
- transition: none !important;
- }
-}
-/* --------------------------------------------------------------------------
- Write-action confirm card & buttons
- -------------------------------------------------------------------------- */
-.dai-root .dai-action {
- padding: 12px 14px;
- border: 1px solid #e2e8f0;
- border-radius: 12px;
- background: #ffffff;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-.dai-root .dai-action-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex: 0 0 auto;
- width: 28px;
- height: 28px;
- border-radius: 8px;
- color: #4f46e5;
- background: rgba(99, 102, 241, 0.1);
-}
-.dai-root .dai-action-summary {
- font-size: 13px;
- line-height: 1.4;
- font-weight: 600;
- color: var(--dai-text);
-}
-.dai-root .dai-action-note {
- font-size: 12px;
- color: #64748b;
- line-height: 1.4;
-}
-.dai-root .dai-ok {
- color: #047857;
- font-weight: 500;
-}
-.dai-root .dai-err {
- color: #b91c1c;
- font-weight: 500;
-}
-.dai-root .dai-action button {
- cursor: pointer;
-}
-/* --------------------------------------------------------------------------
- Overflow containment
- --------------------------------------------------------------------------
- Flex items default to `min-width: auto`, which means they refuse to shrink
- below their content's intrinsic width. Two side-by-side TextInputs in the
- create-customer form therefore pushed the message column wider than the
- panel and produced a horizontal scrollbar. Every link in the chain from the
- scroll region down to the field has to opt out of that.
- -------------------------------------------------------------------------- */
-.dai-root .dai-thread,
-.dai-root .dai-msg,
-.dai-root .dai-msg-row,
-.dai-root .dai-action,
-.dai-root .dai-form {
- min-width: 0;
- max-width: 100%;
-}
-/* Two fields per row that genuinely share the width. `flex-wrap` is the
- belt-and-braces part: if a field ever can't compress far enough (a long
- label, a narrower panel), the row drops to two lines instead of pushing the
- panel sideways again. */
-.dai-root .dai-form-row {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- width: 100%;
- min-width: 0;
-}
-.dai-root .dai-form-row > * {
- flex: 1 1 140px;
- min-width: 0;
-}
-/* Astryx's TextInput sizes itself to content unless told otherwise. */
-.dai-root .dai-form input,
-.dai-root .dai-form-row input {
- width: 100%;
- min-width: 0;
- box-sizing: border-box;
-}
-.dai-root .dai-field-err {
- font-size: 11.5px;
- line-height: 1.4;
- color: #b91c1c;
-}
-/* --------------------------------------------------------------------------
- Source footer + full lists
- -------------------------------------------------------------------------- */
-
-/* Timestamp left, Sources toggle hard right, both on ONE line. It was a
- wrapping baseline row, and the toggle — a shrinkable flex item with inline
- content — gave up its chevron to a second line whenever the panel got
- narrow, which read as a stray arrow floating under the text. */
-.dai-root .dai-msg-foot {
- display: flex;
- align-items: center;
- flex-wrap: nowrap;
- gap: 10px;
- width: 100%;
- min-width: 0;
-}
-/* The timestamp is the only part allowed to give up space, and it ellipsises
- rather than wrapping. */
-.dai-root .dai-msg-foot .dai-msg-footer {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-/* The toggle is one atom: label and chevron cannot be separated. */
-.dai-root .dai-msg-foot .dai-sources {
- flex: 0 0 auto;
- display: inline-flex;
- align-items: center;
- gap: 4px;
- align-self: center;
- white-space: nowrap;
- padding: 3px 7px;
- margin-right: -7px;
- border-radius: 5px;
- color: var(--dai-text-muted);
- transition: background 140ms ease, color 140ms ease;
-}
-.dai-root .dai-msg-foot .dai-sources:hover {
- background: var(--dai-surface-hover);
- color: var(--dai-text);
- text-decoration: none;
-}
-.dai-root .dai-msg-foot .dai-sources svg {
- flex: 0 0 auto;
- transition: transform 160ms var(--dai-ease);
-}
-.dai-root .dai-msg-foot .dai-sources[aria-expanded='true'] svg {
- transform: rotate(180deg);
-}
-/* A full, readable list instead of "…and 4 more". Scrolls past ~12 rows so a
- long list can never push the panel's height around. */
-.dai-root .dai-list {
- width: 100%;
- min-width: 0;
- border: 1px solid var(--dai-border);
- border-radius: 5px;
- background: var(--dai-surface-alt);
- overflow: hidden;
-}
-.dai-root .dai-list-head {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 8px;
- padding: 7px 10px;
- border-bottom: 1px solid var(--dai-border);
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--dai-text-muted);
-}
-.dai-root .dai-list-body {
- max-height: 260px;
- overflow-y: auto;
- overflow-x: hidden;
- overscroll-behavior: contain;
-}
-.dai-root .dai-list-item {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- gap: 2px 8px;
- padding: 6px 10px;
- font-size: 12.5px;
- line-height: 1.4;
- color: var(--dai-text);
- border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05));
-}
-.dai-root .dai-list-item:first-child {
- border-top: none;
-}
-.dai-root .dai-list-index {
- flex: 0 0 auto;
- min-width: 18px;
- font-size: 11px;
- font-variant-numeric: tabular-nums;
- color: var(--dai-text-muted);
-}
-/* The label must not be crushed to zero width by a long value — that is what
- made "address" render one letter per line. It sizes to its content and the
- value takes the remaining space, wrapping onto its own full-width line when
- there isn't enough room for both. */
-.dai-root .dai-list-label {
- flex: 0 1 auto;
- min-width: 0;
- overflow-wrap: anywhere;
-}
-.dai-root .dai-list-meta {
- flex: 1 1 auto;
- min-width: 0;
- text-align: right;
- font-size: 11.5px;
- color: var(--dai-text-secondary);
- overflow-wrap: anywhere;
-}
-/* A value that wrapped to its own line reads better left-aligned under its
- label than pinned to the right edge. */
-.dai-root .dai-list-item > .dai-list-meta:only-child,
-.dai-root .dai-list-meta[data-wrapped='true'] {
- text-align: left;
-}
-/* --------------------------------------------------------------------------
- Documentation answers
- --------------------------------------------------------------------------
- Retrieved passages are shown VERBATIM with their source file. They are prose
- rather than a figure, so they get a quieter, wider treatment than a metric —
- and the source line matters as much as the text, because the operator needs
- to know this came from a document, not from live data.
- -------------------------------------------------------------------------- */
-.dai-root .dai-doc {
- padding: 10px 11px;
- border: 1px solid var(--dai-border);
- border-left: 2px solid var(--dai-ai-from);
- border-radius: 5px;
- background: var(--dai-surface-alt);
- font-size: 12.5px;
- line-height: 1.55;
- color: var(--dai-text);
- white-space: pre-line;
- overflow-wrap: anywhere;
- max-height: 320px;
- overflow-y: auto;
-}
-.dai-root .dai-doc-source {
- font-size: 11px;
- color: var(--dai-text-muted);
- font-variant-numeric: tabular-nums;
-}
diff --git a/src/lib/assistant/assignActions.js b/src/lib/assistant/assignActions.js
index c93fe8b..7f7c0e3 100644
--- a/src/lib/assistant/assignActions.js
+++ b/src/lib/assistant/assignActions.js
@@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom.
- // eslint-disable-next-line no-restricted-syntax
+
for (const t of targets) {
try {
- // eslint-disable-next-line no-await-in-loop
+
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1;
assignedRiders.add(String(t.mileruserid));
@@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => {
// drops back should feel one buzz, not ten.
let notified = 0;
if (lookup) {
- // eslint-disable-next-line no-restricted-syntax
+
for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) {
try {
- // eslint-disable-next-line no-await-in-loop
+
await notifyRider(rider.milerprofileid);
notified += 1;
} catch {
diff --git a/src/lib/assistant/bulkFlow.js b/src/lib/assistant/bulkFlow.js
index aebeb1b..d2ce0b6 100644
--- a/src/lib/assistant/bulkFlow.js
+++ b/src/lib/assistant/bulkFlow.js
@@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
if (hasCoords(row)) {
located.push(row);
- // eslint-disable-next-line no-continue
+
continue;
}
const key = cacheKey(row);
if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) });
- // eslint-disable-next-line no-continue
+
continue;
}
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
- // eslint-disable-next-line no-await-in-loop
+
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = {
deliverylatitude: place?.geometry?.location?.lat?.(),
@@ -147,7 +147,7 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres
// Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast.
- // eslint-disable-next-line no-await-in-loop
+
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
}
diff --git a/src/lib/assistant/bulkOrderActions.js b/src/lib/assistant/bulkOrderActions.js
index 5e36bf7..4aa0963 100644
--- a/src/lib/assistant/bulkOrderActions.js
+++ b/src/lib/assistant/bulkOrderActions.js
@@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (String(row.finalprice ?? '') !== '') {
out.push(row);
- // eslint-disable-next-line no-continue
+
continue;
}
if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
- // eslint-disable-next-line no-continue
+
continue;
}
- // eslint-disable-next-line no-await-in-loop
+
const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
@@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should
if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' });
- // eslint-disable-next-line no-continue
+
continue;
}
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
@@ -209,7 +209,7 @@ export const executeCreateBulk = async (rows, shared) => {
const batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try {
- // eslint-disable-next-line no-await-in-loop
+
const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
@@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => {
status: 'error',
errorMessage: res.message || 'Rejected'
});
- // eslint-disable-next-line no-continue
+
continue;
}
diff --git a/src/lib/assistant/customerFlow.js b/src/lib/assistant/customerFlow.js
index aac1c7c..6307c59 100644
--- a/src/lib/assistant/customerFlow.js
+++ b/src/lib/assistant/customerFlow.js
@@ -103,7 +103,7 @@ export const advance = (flow) => {
const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) {
step += 1;
- // eslint-disable-next-line no-continue
+
continue;
}
return { flow: { ...flow, step }, ask: s.ask, done: false };
diff --git a/src/lib/assistant/flowEngine.js b/src/lib/assistant/flowEngine.js
index 5944584..09705b8 100644
--- a/src/lib/assistant/flowEngine.js
+++ b/src/lib/assistant/flowEngine.js
@@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => {
const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1;
- // eslint-disable-next-line no-continue
+
continue;
}
if (s.auto) {
- // eslint-disable-next-line no-await-in-loop
+
const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value);
step += 1;
- // eslint-disable-next-line no-continue
+
continue;
}
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
diff --git a/src/lib/assistant/flows.js b/src/lib/assistant/flows.js
deleted file mode 100644
index ed0680a..0000000
--- a/src/lib/assistant/flows.js
+++ /dev/null
@@ -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.` };
-};
diff --git a/src/lib/assistant/intents.js b/src/lib/assistant/intents.js
index c1fd5de..67867f5 100644
--- a/src/lib/assistant/intents.js
+++ b/src/lib/assistant/intents.js
@@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => {
let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
- // eslint-disable-next-line no-await-in-loop
+
const next = await getBookingsPageCached(page);
lastPageFetched = page;
if (!next.rows.length) {
@@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => {
for (const intent of INTENTS) {
const params = intent.match(text);
if (!params) continue;
- // eslint-disable-next-line no-await-in-loop
+
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params };
}
@@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => {
if (segments.length < 2) return null;
const results = [];
for (const segment of segments) {
- // eslint-disable-next-line no-await-in-loop
+
const r = await matchAndRun(segment);
if (r) results.push(r);
}
@@ -2243,7 +2243,7 @@ export async function answerQuestion(text, context = {}) {
// WHAT. Embeddings are good at the former and unreliable at the latter.
const params = intent?.match(normalized);
if (intent && params) {
- // eslint-disable-next-line no-await-in-loop
+
const result = await intent.run(params);
if (result) return { ...result, intentId: intent.id, params, routing: routed };
}
diff --git a/src/lib/assistant/pageContext.js b/src/lib/assistant/pageContext.js
deleted file mode 100644
index be9f7e8..0000000
--- a/src/lib/assistant/pageContext.js
+++ /dev/null
@@ -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';
diff --git a/src/lib/assistant/repeatRuns.js b/src/lib/assistant/repeatRuns.js
index df41b48..bab3601 100644
--- a/src/lib/assistant/repeatRuns.js
+++ b/src/lib/assistant/repeatRuns.js
@@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
if (shouldStop?.()) break;
const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
- // eslint-disable-next-line no-await-in-loop
+
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out);
}
diff --git a/src/pages/doormile/ViewProfile.jsx b/src/pages/doormile/ViewProfile.jsx
index 58aa235..2a653a4 100644
--- a/src/pages/doormile/ViewProfile.jsx
+++ b/src/pages/doormile/ViewProfile.jsx
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
-import { Building2, KeyRound, Mail, Shield, User } from 'lucide-react';
+import { Building2, KeyRound, Mail, Shield } from 'lucide-react';
import {
- Avatar, Button, Field, Grid, Input, PageHeader, SectionHeader, Stack, Surface,
+ Avatar, Button, Field, Grid, Input, SectionHeader, Stack, Surface,
} from '@/components/ds';
import { useProfile, useUpdatePassword } from '@/lib/doormileHooks';
import { orDash } from '@/lib/doormileFormat';
diff --git a/src/pages/doormile/bookings/Bookings.jsx b/src/pages/doormile/bookings/Bookings.jsx
index 4ec0899..e6b74a2 100644
--- a/src/pages/doormile/bookings/Bookings.jsx
+++ b/src/pages/doormile/bookings/Bookings.jsx
@@ -2,8 +2,8 @@ import React, { useMemo, useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import {
- Ban, Bike, BookMarked, CheckCircle2, Clock, FileSpreadsheet,
- Package, PackageSearch, RefreshCw, Sparkles, UserCheck, XCircle,
+ Ban, Bike,
+ Package, PackageSearch, RefreshCw,
} from 'lucide-react';
import {
@@ -16,7 +16,6 @@ import { OpenToast, messageOf } from '@/api/doormile/notify';
import { drainBookings } from '@/lib/assistant/scan';
import {
useBooking, useAssignMilerToBooking, useRiderOptions,
- useCancelBooking, useBulkCancelBookings,
} from '@/lib/doormileHooks';
import { assignMilerToBooking, notifyMiler } from '@/api/doormile/endpoints';
import {
diff --git a/src/pages/doormile/clients/CreateClient.jsx b/src/pages/doormile/clients/CreateClient.jsx
index aa8a915..dd2cf5d 100644
--- a/src/pages/doormile/clients/CreateClient.jsx
+++ b/src/pages/doormile/clients/CreateClient.jsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { ArrowLeft, Building2 } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,
diff --git a/src/pages/doormile/clients/CreateCustomer.jsx b/src/pages/doormile/clients/CreateCustomer.jsx
index edd1b6d..fa8a909 100644
--- a/src/pages/doormile/clients/CreateCustomer.jsx
+++ b/src/pages/doormile/clients/CreateCustomer.jsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { ArrowLeft, Users } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, SectionHeader, Stack, Surface,
diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx
index f41047d..41844d2 100644
--- a/src/pages/doormile/deliveries/Deliveries.jsx
+++ b/src/pages/doormile/deliveries/Deliveries.jsx
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
-import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react';
+import { Ban, Bell, Bike, FileSpreadsheet, PackageSearch, Truck } from 'lucide-react';
import {
- Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, Input,
+ Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton,
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
} from '@/components/ds';
diff --git a/src/pages/doormile/home/Home.jsx b/src/pages/doormile/home/Home.jsx
index 2eeeaac..6660e26 100644
--- a/src/pages/doormile/home/Home.jsx
+++ b/src/pages/doormile/home/Home.jsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
-import { motion, AnimatePresence } from 'framer-motion';
+import { motion } from 'framer-motion';
import dayjs from 'dayjs';
import {
ArrowRight,
@@ -22,28 +22,21 @@ import {
Send,
Shield,
Sparkles,
- Table,
Upload,
- Users,
- Volume2,
- VolumeX,
- X
+ Users
} from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { DOORMILE_WORDMARK_URL } from '@/assets/brand';
import { cn } from '@/lib/utils';
import { OpenToast } from '@/components/third-party/OpenToast';
-import { answerQuestion, EXAMPLE_QUESTIONS, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents';
-import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions';
+import { answerQuestion } from '@/lib/assistant/intents';
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions';
import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions';
-import { startCustomerFlow, answerStep } from '@/lib/assistant/customerFlow';
import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow';
-import { startBulkFlow, answerBulkStep } from '@/lib/assistant/bulkFlow';
-import { startAssignFlow, answerAssignStep } from '@/lib/assistant/assignFlow';
+import { answerAssignStep } from '@/lib/assistant/assignFlow';
import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions';
import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow';
-import { buildRepeatRun, describeDay, findRecentRuns } from '@/lib/assistant/repeatRuns';
+import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns';
const LOGO_FILTER = {
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)',
diff --git a/src/pages/doormile/orders/Orders.jsx b/src/pages/doormile/orders/Orders.jsx
index d94f663..2ba2fe0 100644
--- a/src/pages/doormile/orders/Orders.jsx
+++ b/src/pages/doormile/orders/Orders.jsx
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import dayjs from 'dayjs';
-import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Truck, Upload, UserCheck } from 'lucide-react';
+import { Ban, Bike, FileSpreadsheet, MapPin, Package, Plus, Sparkles, Upload, UserCheck } from 'lucide-react';
import { Button, ConfirmModal, DataTable, DatePicker, EmptyState, Modal, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
import { useBulkCancelBookings, useCustomers, useFlowAnchors, useRiderOptions } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext';
diff --git a/src/pages/doormile/riders/CreateRider.jsx b/src/pages/doormile/riders/CreateRider.jsx
index d3314df..94d67c0 100644
--- a/src/pages/doormile/riders/CreateRider.jsx
+++ b/src/pages/doormile/riders/CreateRider.jsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { ArrowLeft, Bike } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,
diff --git a/src/pages/doormile/riders/EditRider.jsx b/src/pages/doormile/riders/EditRider.jsx
index ce48cc0..25692c5 100644
--- a/src/pages/doormile/riders/EditRider.jsx
+++ b/src/pages/doormile/riders/EditRider.jsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
-import { ArrowLeft, Bike } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select,