415 lines
18 KiB
JavaScript
415 lines
18 KiB
JavaScript
import {
|
|
LuClock3,
|
|
LuPackage,
|
|
LuBike,
|
|
LuTruck,
|
|
LuBuilding2,
|
|
LuCircleDot,
|
|
LuBanknote,
|
|
LuUsers,
|
|
LuLayers,
|
|
LuTimerOff,
|
|
LuUserPlus,
|
|
LuPackagePlus,
|
|
LuListPlus,
|
|
LuRepeat
|
|
} from 'react-icons/lu';
|
|
|
|
// ==============================|| Doormile AI — page context ||============================== //
|
|
//
|
|
// Maps the current route to (a) a short context label shown under the panel
|
|
// header and (b) the suggested questions offered in the welcome state, so the
|
|
// assistant opens on questions relevant to what the operator is looking at
|
|
// rather than a fixed Orders-shaped list.
|
|
//
|
|
// Every suggestion here MUST be a phrasing the deterministic matcher in
|
|
// intents.js actually resolves — a suggestion chip that returns "I can't
|
|
// answer that yet" is worse than no chip. When adding one, check it against
|
|
// the INTENTS catalog first.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Short chip labels.
|
|
//
|
|
// Every `text` below is the exact phrasing the deterministic matcher in
|
|
// intents.js resolves, so it CANNOT be shortened in place — that is the string
|
|
// the click actually asks. This map only changes what the chip DISPLAYS.
|
|
//
|
|
// It exists because the welcome state offers up to twenty suggestions, and as
|
|
// full sentences they are 130-255px wide in a ~393px panel: barely two fit per
|
|
// row, so the "chips" laid out as fourteen near-full-width rows and read as a
|
|
// list again. Shortened, three or four fit per row and the block becomes
|
|
// something you scan.
|
|
//
|
|
// The full question stays the accessible name and the tooltip, so nothing is
|
|
// lost for screen readers or for anyone unsure what a chip will ask. A
|
|
// suggestion with no entry here simply shows its full text.
|
|
// ---------------------------------------------------------------------------
|
|
export const CHIP_LABELS = {
|
|
"Give me today's operations summary": "Today's summary",
|
|
'How many orders today?': 'Orders today',
|
|
'Which orders are delayed?': 'Delayed orders',
|
|
'Create an order': 'Create order',
|
|
'Create multiple orders': 'Bulk orders',
|
|
'Total revenue today': 'Revenue today',
|
|
'How many pending orders today?': 'Pending today',
|
|
'How many cancelled orders today?': 'Cancelled today',
|
|
'How many delivered orders today?': 'Delivered today',
|
|
'Morning Batch orders today': 'Morning batch',
|
|
'Orders today vs yesterday': 'Today vs yesterday',
|
|
'Create a customer': 'Create customer',
|
|
'How many riders are active?': 'Active riders',
|
|
'How many vehicles are available?': 'Vehicles available',
|
|
'Current hub status': 'Hub status',
|
|
'Which hubs are experiencing delays?': 'Hub delays',
|
|
'How many tenants do we have?': 'Tenants',
|
|
'How many orders this week?': 'Orders this week',
|
|
'Total revenue this week': 'Revenue this week',
|
|
'How many customers do we have?': 'Customers',
|
|
'Assign a rider': 'Assign order',
|
|
"Repeat yesterday's orders": 'Repeat yesterday'
|
|
};
|
|
|
|
// The one follow-up phrasing that is not already a page suggestion. Verified
|
|
// against ASSIGN_TRIGGER in assignActions.js — it opens the assign-rider flow.
|
|
const ASSIGN_TEXT = 'Assign a rider';
|
|
|
|
// Recreates a previous day's run rather than building an order from scratch.
|
|
// This is the phrasing intents.js documents for it ("repeat yesterday's
|
|
// orders") and it is matched by REPEAT_TRIGGER in repeatRuns.js, which is
|
|
// ordered AHEAD of createOrder/createBulkOrders precisely so a sentence
|
|
// containing both "repeat" and "orders" recalls the run instead of opening a
|
|
// blank create form.
|
|
const REPEAT_TEXT = "Repeat yesterday's orders";
|
|
|
|
// Not a question — an OUTCOME. AIPanel sets this as the follow-up key when a
|
|
// create or repeat run actually succeeds, so the next steps offered are the
|
|
// ones that follow from an order existing, rather than the ones that follow
|
|
// from having asked to make one. Assigning a rider only makes sense on this
|
|
// side of the write.
|
|
export const ORDER_CREATED = '__orderCreated';
|
|
|
|
// The same write, but with every new order already given a rider — a repeat run
|
|
// that handed each copy back to whoever ran it yesterday.
|
|
//
|
|
// It needs its own key purely so ASSIGN_TEXT can be left out. Offering "Assign
|
|
// order" after the bot has just assigned them all presents finished work as a
|
|
// pending step, and an operator who takes the suggestion is walked into
|
|
// reassigning orders that are already out.
|
|
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
|
|
|
|
const ORDERS = {
|
|
label: 'Orders',
|
|
suggestions: [
|
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
|
{ icon: LuPackage, text: 'How many orders today?' },
|
|
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
|
{ icon: LuPackagePlus, text: 'Create an order' },
|
|
{ icon: LuRepeat, text: REPEAT_TEXT }
|
|
],
|
|
more: [
|
|
{ icon: LuListPlus, text: 'Create multiple orders' },
|
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
|
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
|
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
|
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
|
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
|
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
|
]
|
|
};
|
|
|
|
const RIDERS = {
|
|
label: 'Riders',
|
|
suggestions: [
|
|
{ icon: LuBike, text: 'How many riders are active?' },
|
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
|
{ icon: LuPackage, text: 'How many orders today?' },
|
|
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
|
],
|
|
more: [
|
|
{ icon: LuBuilding2, text: 'Current hub status' },
|
|
{ icon: LuBanknote, text: 'Total revenue today' }
|
|
]
|
|
};
|
|
|
|
const VEHICLES = {
|
|
label: 'Vehicles',
|
|
suggestions: [
|
|
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
|
{ icon: LuBike, text: 'How many riders are active?' },
|
|
{ icon: LuBuilding2, text: 'Current hub status' },
|
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
|
],
|
|
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
|
};
|
|
|
|
const HUBS = {
|
|
label: 'Hubs',
|
|
suggestions: [
|
|
{ icon: LuBuilding2, text: 'Current hub status' },
|
|
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
|
|
{ icon: LuTruck, text: 'How many vehicles are available?' },
|
|
{ icon: LuBike, text: 'How many riders are active?' }
|
|
],
|
|
more: [{ icon: LuPackage, text: 'How many orders today?' }]
|
|
};
|
|
|
|
const DISPATCH = {
|
|
label: 'Dispatch',
|
|
suggestions: [
|
|
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
|
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
|
{ icon: LuBike, text: 'How many riders are active?' },
|
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
|
],
|
|
more: [
|
|
{ icon: LuPackage, text: 'How many orders today?' },
|
|
{ icon: LuCircleDot, text: 'How many pending orders today?' }
|
|
]
|
|
};
|
|
|
|
const TENANTS = {
|
|
label: 'Tenants',
|
|
suggestions: [
|
|
{ icon: LuUsers, text: 'How many tenants do we have?' },
|
|
{ icon: LuPackage, text: 'How many orders today?' },
|
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
|
],
|
|
more: []
|
|
};
|
|
|
|
const REPORTS = {
|
|
label: 'Reports',
|
|
suggestions: [
|
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
|
{ icon: LuClock3, text: 'Orders today vs yesterday' },
|
|
{ icon: LuPackage, text: 'How many orders this week?' },
|
|
{ icon: LuClock3, text: "Give me today's operations summary" }
|
|
],
|
|
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
|
|
};
|
|
|
|
const DEFAULT_CONTEXT = {
|
|
label: 'Operations',
|
|
suggestions: [
|
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
|
{ icon: LuPackage, text: 'How many orders today?' },
|
|
{ icon: LuBike, text: 'How many riders are active?' },
|
|
{ icon: LuBuilding2, text: 'Current hub status' }
|
|
],
|
|
more: [
|
|
{ icon: LuBanknote, text: 'Total revenue today' },
|
|
{ icon: LuTruck, text: 'How many vehicles are available?' }
|
|
]
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Follow-ups.
|
|
//
|
|
// What the suggestion strip offers AFTER a question has been asked. Showing
|
|
// all twenty again is noise — the operator has already told you what they are
|
|
// doing, so the next set should be the natural next steps for it. Creating an
|
|
// order leads to assigning a rider and to what is still pending; asking about
|
|
// revenue leads to the week and to the day-over-day comparison.
|
|
//
|
|
// Every string here is either one of the CHIP_LABELS questions above (all of
|
|
// which the matcher already resolves) or ASSIGN_TEXT. That constraint is the
|
|
// whole point: pageContext's rule is that a suggestion which returns "I can't
|
|
// answer that yet" is worse than no suggestion, and a follow-up is MORE likely
|
|
// to be clicked than a cold one, because it arrives exactly when it is
|
|
// relevant. Do not add a phrasing here without checking it against INTENTS.
|
|
// ---------------------------------------------------------------------------
|
|
const FOLLOW_UPS = {
|
|
// --- write actions ---
|
|
// Note what is NOT here: assigning a rider.
|
|
//
|
|
// "Create an order" only STARTS the create conversation — no order exists at
|
|
// that moment, so offering to assign one is offering to act on nothing. And
|
|
// once the order really is created, AIPanel already drops the operator
|
|
// straight into the assign flow itself (see the confirmOrder branch), because
|
|
// that hand-off is mandatory rather than optional. A chip there would either
|
|
// duplicate a step the product performs on its own or fire it too early.
|
|
//
|
|
// ASSIGN_TEXT belongs on the questions below that surface orders which
|
|
// already EXIST and are waiting for a rider — and on ORDER_CREATED, which is
|
|
// keyed off the write succeeding rather than off anything that was asked.
|
|
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
|
|
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
|
|
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
|
|
'Create a customer': ['Create an order', 'How many customers do we have?'],
|
|
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
|
|
[ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
|
// No ASSIGN_TEXT: there is nothing left to assign.
|
|
[ORDER_CREATED_ASSIGNED]: ['How many riders are active?', 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
|
|
|
// --- order volume ---
|
|
"Give me today's operations summary": [
|
|
'How many pending orders today?',
|
|
'Which orders are delayed?',
|
|
'Total revenue today',
|
|
'How many riders are active?'
|
|
],
|
|
'How many orders today?': [
|
|
'How many pending orders today?',
|
|
'How many delivered orders today?',
|
|
'Orders today vs yesterday',
|
|
'Total revenue today'
|
|
],
|
|
'How many pending orders today?': [ASSIGN_TEXT, 'Which orders are delayed?', 'How many delivered orders today?'],
|
|
'How many delivered orders today?': ['How many pending orders today?', 'Total revenue today', 'Orders today vs yesterday'],
|
|
'How many cancelled orders today?': ['How many delivered orders today?', 'How many orders today?', 'Which orders are delayed?'],
|
|
'Which orders are delayed?': [ASSIGN_TEXT, 'Which hubs are experiencing delays?', 'How many riders are active?'],
|
|
'Morning Batch orders today': ['Which orders are delayed?', 'How many riders are active?', 'How many orders today?'],
|
|
|
|
// --- money and trend ---
|
|
'Total revenue today': ['Total revenue this week', 'Orders today vs yesterday', 'How many orders today?'],
|
|
'Total revenue this week': ['How many orders this week?', 'Total revenue today'],
|
|
'Orders today vs yesterday': [REPEAT_TEXT, 'Total revenue today', 'How many orders this week?'],
|
|
'How many orders this week?': ['Total revenue this week', 'How many orders today?'],
|
|
|
|
// --- fleet and network ---
|
|
'How many riders are active?': ['How many vehicles are available?', 'Current hub status', ASSIGN_TEXT],
|
|
'How many vehicles are available?': ['How many riders are active?', 'Current hub status'],
|
|
'Current hub status': ['Which hubs are experiencing delays?', 'How many vehicles are available?', 'How many riders are active?'],
|
|
'Which hubs are experiencing delays?': ['Current hub status', 'Which orders are delayed?', 'How many riders are active?'],
|
|
|
|
// --- accounts ---
|
|
'How many tenants do we have?': ['How many customers do we have?', 'How many orders today?'],
|
|
'How many customers do we have?': ['Create a customer', 'How many tenants do we have?']
|
|
};
|
|
|
|
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
|
// with the wrong label.
|
|
const ROUTES = [
|
|
['/doormile/dispatch', DISPATCH],
|
|
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
|
|
['/doormile/orders', ORDERS],
|
|
['/doormile/milers', RIDERS],
|
|
['/doormile/riders', RIDERS],
|
|
['/doormile/vehicles', VEHICLES],
|
|
['/doormile/hubs', HUBS],
|
|
['/doormile/tenants', TENANTS],
|
|
['/doormile/reports', REPORTS],
|
|
[
|
|
'/doormile/customers',
|
|
{
|
|
label: 'Customers',
|
|
suggestions: [
|
|
{ icon: LuUserPlus, text: 'Create a customer' },
|
|
{ icon: LuUsers, text: 'How many customers do we have?' },
|
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
|
{ icon: LuPackage, text: 'How many orders today?' }
|
|
],
|
|
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' }]
|
|
];
|
|
|
|
// ---- every question, from every page ---------------------------------------
|
|
//
|
|
// The catalog is the same wherever you are: the assistant can answer 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. What the page still decides is ORDER — its own questions lead,
|
|
// then everything else follows, deduplicated by text.
|
|
//
|
|
// `more` is retired: AIWelcome renders one flat list now, so a second array
|
|
// would only be a second place for a question to hide.
|
|
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
|
|
|
// 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.
|
|
const EVERY_QUESTION = (() => {
|
|
const seen = new Set();
|
|
const out = [];
|
|
ALL_CONTEXTS.forEach((c) => {
|
|
[...(c.suggestions || []), ...(c.more || [])].forEach((q) => {
|
|
if (seen.has(q.text)) return;
|
|
seen.add(q.text);
|
|
out.push(q);
|
|
});
|
|
});
|
|
return out;
|
|
})();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The questions that ride 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.
|
|
//
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
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) => {
|
|
const seen = new Set();
|
|
const suggestions = [];
|
|
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
|
|
if (seen.has(q.text)) return;
|
|
seen.add(q.text);
|
|
suggestions.push(q);
|
|
});
|
|
return { ...context, suggestions, more: [] };
|
|
};
|
|
|
|
export const getPageContext = (pathname = '') => {
|
|
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
|
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Follow-ups are stored as bare strings (they have to be — they are the exact
|
|
// text that gets asked), so this turns one back into a renderable chip by
|
|
// looking its icon up among the questions already declared above. Built from
|
|
// EVERY_QUESTION rather than a second hand-written list, so a suggestion can
|
|
// never end up with an icon here that disagrees with its icon on a page.
|
|
// ---------------------------------------------------------------------------
|
|
const QUESTION_BY_TEXT = new Map(EVERY_QUESTION.map((q) => [q.text, q]));
|
|
QUESTION_BY_TEXT.set(ASSIGN_TEXT, { icon: LuBike, text: ASSIGN_TEXT });
|
|
|
|
// Returns [] for anything unrecognised — a typed question, or one with no
|
|
// natural next step. The caller falls back to the page's own suggestions, so
|
|
// the strip is never empty.
|
|
export const getFollowUps = (askedText) => {
|
|
const next = FOLLOW_UPS[askedText];
|
|
if (!next) return [];
|
|
return next.map((text) => QUESTION_BY_TEXT.get(text)).filter(Boolean);
|
|
};
|
|
|
|
// Turns arbitrary follow-up strings into chips. Used for the intent-keyed
|
|
// FOLLOW_UP_SUGGESTIONS in intents.js, whose phrasings ("What about
|
|
// yesterday?", "This week") are conversational rather than one of the declared
|
|
// questions, so they have no icon of their own.
|
|
export const toChips = (texts = []) => texts.map((text) => QUESTION_BY_TEXT.get(text) || { icon: LuCircleDot, text });
|