implemenation on the bot
This commit is contained in:
@@ -20,7 +20,7 @@ import { StatusDot } from '@astryxdesign/core/StatusDot';
|
||||
|
||||
export default function PageHeader({ title, subtitle, live = false, action }) {
|
||||
return (
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={2}>
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Heading level={1} maxLines={1}>
|
||||
{title}
|
||||
|
||||
@@ -60,7 +60,7 @@ const FILL_CLASS = 'dt-page-fill';
|
||||
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
|
||||
// silently, which made every page-scoped rule fail to match with no error —
|
||||
// the styles simply never applied.
|
||||
export function PageShell({ children, gap = 6, fill = false, className }) {
|
||||
export function PageShell({ children, gap = 4, fill = false, className }) {
|
||||
return (
|
||||
<>
|
||||
{fill && (
|
||||
@@ -105,10 +105,10 @@ PageShell.propTypes = {
|
||||
// `max` caps the column count so 3 cards don't each become 400px wide on an
|
||||
// ultrawide monitor; it defaults to the number of children.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function KpiGrid({ children, minWidth = 240, max }) {
|
||||
export function KpiGrid({ children, minWidth = 180, gap = 3, max }) {
|
||||
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
|
||||
return (
|
||||
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={4}>
|
||||
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
@@ -117,6 +117,7 @@ export function KpiGrid({ children, minWidth = 240, max }) {
|
||||
KpiGrid.propTypes = {
|
||||
children: PropTypes.node,
|
||||
minWidth: PropTypes.number,
|
||||
gap: PropTypes.number,
|
||||
max: PropTypes.number
|
||||
};
|
||||
|
||||
|
||||
@@ -7,53 +7,42 @@ import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||
|
||||
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== //
|
||||
// Clean metric card: muted eyebrow label, large tabular value, a single
|
||||
// soft-tinted rounded tile holding the icon (with a faint matching ring for
|
||||
// depth). A low resting elevation lifts the tile off the page instead of
|
||||
// reading as a flat bordered box. No coloured top-stripe, no rainbow —
|
||||
// colour appears only in the icon tile so a row of cards reads calm and
|
||||
// corporate.
|
||||
// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
|
||||
// Modern, space-efficient KPI tile:
|
||||
// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
|
||||
// - Soft ambient accent gradient tint based on semantic status color.
|
||||
// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
|
||||
// - Micro-hover interaction (subtle lift + shadow depth).
|
||||
//
|
||||
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
|
||||
// `color` is a hex accent (defaults to brand red); `caption` is the small
|
||||
// muted line under the value (e.g. "96% of total").
|
||||
//
|
||||
// Astryx note: the icon tile keeps an inline `style` because the accent is a
|
||||
// per-call hex from the caller (each KPI tile carries its own semantic status
|
||||
// colour — see CLAUDE.md's status palette), which Astryx's fixed `variant`
|
||||
// and semantic `color` enums can't express. Same escape hatch login.js uses
|
||||
// for the brand gradient panel.
|
||||
// `color` is a hex accent (defaults to brand black); `caption` is optional supporting text.
|
||||
|
||||
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
|
||||
return (
|
||||
<Card height="100%" padding={2} elevation="low">
|
||||
<HStack justify="between" vAlign="center" gap={2}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0} width="100%">
|
||||
<Text type="supporting" size="xsm" weight="semibold" maxLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Skeleton width={60} height={26} radius={2} />
|
||||
) : (
|
||||
<Text size="xl" weight="bold" display="block" maxLines={1} hasTabularNumbers>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{caption && (
|
||||
<Text type="supporting" size="sm" display="block">
|
||||
{caption}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<Card
|
||||
height="100%"
|
||||
padding={1.5}
|
||||
elevation="none"
|
||||
className="dt-stat-card"
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
|
||||
border: `1px solid ${color}24`,
|
||||
borderRadius: 'var(--radius-container, 6px)',
|
||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
||||
cursor: 'default'
|
||||
}}
|
||||
>
|
||||
<HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
|
||||
{icon && (
|
||||
<Center
|
||||
width={36}
|
||||
height={36}
|
||||
width={34}
|
||||
height={34}
|
||||
className="dt-stat-icon"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: 'var(--radius-element)',
|
||||
backgroundColor: `${color}16`,
|
||||
borderRadius: 'var(--radius-element, 6px)',
|
||||
backgroundColor: `${color}14`,
|
||||
color,
|
||||
border: `1px solid ${color}2A`
|
||||
}}
|
||||
@@ -61,6 +50,51 @@ export default function StatCard({ title, value, icon, color = '#000000', captio
|
||||
{icon}
|
||||
</Center>
|
||||
)}
|
||||
<VStack gap={0} padding={0} minWidth={0} style={{ flex: 1 }}>
|
||||
<Text
|
||||
type="supporting"
|
||||
size="xsm"
|
||||
weight="semibold"
|
||||
maxLines={1}
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--color-text-supporting, #64748b)',
|
||||
letterSpacing: '0.01em',
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Skeleton width={48} height={20} radius={2} style={{ marginTop: 2 }} />
|
||||
) : (
|
||||
<Text
|
||||
size="lg"
|
||||
weight="bold"
|
||||
display="block"
|
||||
maxLines={1}
|
||||
hasTabularNumbers
|
||||
style={{
|
||||
fontSize: '19px',
|
||||
lineHeight: 1.2,
|
||||
color: 'var(--color-text-primary, #0f172a)',
|
||||
marginTop: 1
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{caption && (
|
||||
<Text
|
||||
type="supporting"
|
||||
size="xsm"
|
||||
display="block"
|
||||
style={{ fontSize: '11px', marginTop: 1 }}
|
||||
>
|
||||
{caption}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -52,3 +52,22 @@ body {
|
||||
*::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* KPI / StatCard sleek interactions & polish */
|
||||
.dt-stat-card {
|
||||
transition: transform 0.18s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.18s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.18s ease !important;
|
||||
}
|
||||
|
||||
.dt-stat-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04) !important;
|
||||
}
|
||||
|
||||
.dt-stat-card .dt-stat-icon {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.dt-stat-card:hover .dt-stat-icon {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,14 @@ import {
|
||||
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
|
||||
// comment) are confirmed; the rest of this mapping is still a best-effort
|
||||
// guess. Unmapped statuses pass through lowercased, which the page's own
|
||||
// fallback renders as an "unknown" badge rather than crashing. There's no
|
||||
// confirmed equivalent for arrived/picked/skipped at all, so those tabs will
|
||||
// show a 0 count until the real enum is confirmed.
|
||||
// fallback renders as an "unknown" badge rather than crashing, AND the
|
||||
// Deliveries page now surfaces those rows under an "Other" tab that names the
|
||||
// unmapped enum — previously such a row was counted nowhere and shown nowhere.
|
||||
// `picked` now has a confirmed source (Converted_To_Consignment, below).
|
||||
// `arrived` and `skipped` still have none: the rider actions that produce them
|
||||
// (POST /miler/bookings/:id/reached, POST /miler/consignments/:id/skip) have no
|
||||
// booking-status equivalent in the confirmed enum, so those two tabs stay at 0
|
||||
// until the backend is confirmed to expose them.
|
||||
//
|
||||
// Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'.
|
||||
// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler
|
||||
@@ -62,11 +67,20 @@ import {
|
||||
// accepted" in the currently-confirmed enum, so that one maps to 'accepted'.
|
||||
// If the backend turns out to have a distinct status specifically for the
|
||||
// accept action, add it here rather than reusing Miler_Assigned for it.
|
||||
// Converted_To_Consignment is PICKED, not accepted. doormile-flow.md §5 is
|
||||
// explicit: `POST /miler/bookings/:bookingid/pickup-complete` "converts the
|
||||
// booking into a consignment" — so the status is written at the moment the
|
||||
// rider completes pickup, and the parcel is in their hands. deliveries.js says
|
||||
// the same thing from the other side ("this order hasn't been picked up yet —
|
||||
// status can only be updated once it becomes a consignment").
|
||||
//
|
||||
// It sat on 'accepted', which held a picked-up parcel in the Accepted tab and
|
||||
// left Picked permanently empty — nothing else in the enum maps to it.
|
||||
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
pending_pickup: 'pending',
|
||||
miler_assigned: 'pending',
|
||||
pickup_scheduled: 'accepted',
|
||||
converted_to_consignment: 'accepted',
|
||||
converted_to_consignment: 'picked',
|
||||
out_for_delivery: 'active',
|
||||
delivered: 'delivered',
|
||||
cancelled: 'cancelled'
|
||||
@@ -135,7 +149,10 @@ export const getRiderPeriodicLogs = async (userid) => {
|
||||
// seeing every hub, unchanged. Fails open (full list) whenever the tenant's
|
||||
// city can't be determined or nothing matches, rather than ever locking an
|
||||
// operator out with an empty picker.
|
||||
const normCity = (s) => String(s || '').trim().toLowerCase();
|
||||
const normCity = (s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
export const fetchAppLocations = async () => {
|
||||
try {
|
||||
@@ -202,7 +219,16 @@ export const fetchPercentageData = async ({ queryKey }) => {
|
||||
};
|
||||
} catch (err) {
|
||||
OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000);
|
||||
return { created: '0', uncoveredOrders: '0', coveredOrders: '0', cancelled: '0', percentage1: '0', percentage2: '0', percentage3: '0', percentage4: '0' };
|
||||
return {
|
||||
created: '0',
|
||||
uncoveredOrders: '0',
|
||||
coveredOrders: '0',
|
||||
cancelled: '0',
|
||||
percentage1: '0',
|
||||
percentage2: '0',
|
||||
percentage3: '0',
|
||||
percentage4: '0'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,7 +353,10 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
|
||||
// jupiter2doormile.md comparison). Falls back to matching the rider's NAME
|
||||
// (also echoed by the solver, see flattenRiders' rider_name) against each
|
||||
// miler's displayname/authname — the only other correlatable field.
|
||||
const normMilerName = (s) => String(s || '').trim().toLowerCase();
|
||||
const normMilerName = (s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
export const buildMilerLookup = (milers) => {
|
||||
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
|
||||
@@ -348,10 +377,10 @@ export const resolveMilerForOrder = (order, lookup) => {
|
||||
const matchedVia = lookup.byUserId.has(String(riderUserId))
|
||||
? 'userid'
|
||||
: lookup.byProfileId.has(String(riderUserId))
|
||||
? 'milerprofileid'
|
||||
: lookup.byName.has(normMilerName(riderName))
|
||||
? 'name'
|
||||
: null;
|
||||
? 'milerprofileid'
|
||||
: lookup.byName.has(normMilerName(riderName))
|
||||
? 'name'
|
||||
: null;
|
||||
const rider =
|
||||
lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName));
|
||||
return rider?.milerprofileid ? { rider, matchedVia } : null;
|
||||
@@ -407,7 +436,11 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
realBookingIds = new Set(realBookings.map((b) => String(b.bookingid)));
|
||||
logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`);
|
||||
} catch (err) {
|
||||
logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message);
|
||||
logger.error(
|
||||
'finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids',
|
||||
err.response?.status,
|
||||
err.response?.data || err.message
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
@@ -464,11 +497,7 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
if (failed.length) {
|
||||
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
|
||||
}
|
||||
const resolvedMilerProfileIds = [
|
||||
...new Set(
|
||||
results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid)
|
||||
)
|
||||
];
|
||||
const resolvedMilerProfileIds = [...new Set(results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid))];
|
||||
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds };
|
||||
};
|
||||
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
|
||||
@@ -536,7 +565,11 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
// parameter is documented in express-console-api.md and guessing one risks a
|
||||
// silent 400 or, worse, a silently-ignored filter), so the range is applied
|
||||
// client-side below, after the rows are normalised.
|
||||
const [, , , , startdate, enddate, rowsPerPage] = queryKey;
|
||||
// queryKey[11] is an OPT-IN date basis. Four pages share this function
|
||||
// (deliveries, Dispatch, reports/ordersDetails, reports/profitability) and
|
||||
// three of them genuinely want "created in this window", so the default is
|
||||
// unchanged and only the Deliveries page passes 'activity'.
|
||||
const [, , , , startdate, enddate, rowsPerPage, , , , , dateBasis] = queryKey;
|
||||
// Unlike the 3 joins below (customers/milers/tenants — each individually
|
||||
// guarded so a failed join just degrades a display field, not the whole
|
||||
// page), a failed bookings call is the one thing this function can't
|
||||
@@ -665,14 +698,23 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
//
|
||||
// A missing/blank bound means "unbounded on that side", which preserves the
|
||||
// old behaviour for any caller that doesn't pass real dates.
|
||||
//
|
||||
// 'activity' additionally keeps a row whose LAST UPDATE falls in the window.
|
||||
// Scoping the Deliveries page purely by creation date meant an order created
|
||||
// yesterday and picked or delivered today was invisible today — which is
|
||||
// exactly why every tab past Accepted read 0 while the day's fresh orders
|
||||
// filled Pending and Accepted. A delivery board has to show what is moving
|
||||
// now, not only what was booked now.
|
||||
const dayOf = (value) => {
|
||||
const t = parseDoormileTimestamp(value);
|
||||
return t.isValid() ? t.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
const inRange = (row) => {
|
||||
if (!startdate && !enddate) return true;
|
||||
const t = parseDoormileTimestamp(row.orderdate);
|
||||
if (!t.isValid()) return false;
|
||||
const day = t.format('YYYY-MM-DD');
|
||||
if (startdate && day < String(startdate)) return false;
|
||||
if (enddate && day > String(enddate)) return false;
|
||||
return true;
|
||||
const days = [dayOf(row.orderdate), dateBasis === 'activity' ? dayOf(row.assigntime) : null].filter(Boolean);
|
||||
if (!days.length) return false;
|
||||
return days.some((day) => (!startdate || day >= String(startdate)) && (!enddate || day <= String(enddate)));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -815,9 +857,7 @@ export const getallridersummary = async () => {
|
||||
// silently inflating one of the two new buckets.
|
||||
const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
|
||||
const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length;
|
||||
const onDelivery = milers.filter((m) =>
|
||||
['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())
|
||||
).length;
|
||||
const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
|
||||
return { total: milers.length, active, inactive: milers.length - active, available, onDelivery };
|
||||
} catch (err) {
|
||||
OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000);
|
||||
|
||||
@@ -16,6 +16,8 @@ An in-console Q&A assistant that answers operator questions about live data —
|
||||
- `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
|
||||
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
|
||||
- `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
|
||||
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
|
||||
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
|
||||
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
|
||||
- `pageContext.js` — route → context label + suggested questions.
|
||||
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
|
||||
@@ -25,7 +27,10 @@ An in-console Q&A assistant that answers operator questions about live data —
|
||||
- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
|
||||
- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
@@ -36,7 +41,23 @@ This was a deliberate, explicit product decision (not a technical limitation wor
|
||||
|
||||
Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
|
||||
|
||||
RAG (retrieval-augmented generation over a document/vector store) was also explicitly considered and rejected as the wrong tool: this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions. The correct "grounded answer" pattern for that is what's already here — a fixed catalog of `{match, run}` pairs, not a vector search.
|
||||
### RAG — rejected for the data, later built for the ROUTING
|
||||
|
||||
RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
|
||||
|
||||
What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
|
||||
|
||||
```
|
||||
question → embed → Chroma → intentId + confidence → the SAME run() → live API call
|
||||
```
|
||||
|
||||
Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
|
||||
|
||||
Three rules that must hold:
|
||||
|
||||
- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
|
||||
- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
|
||||
- **Write intents need high confidence.** A semantic near-miss must never open a create form.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,6 +102,8 @@ A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, a
|
||||
- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
|
||||
- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
|
||||
- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
|
||||
- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
|
||||
- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
|
||||
- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
|
||||
|
||||
### Ordering and cross-domain guards — read before adding an intent
|
||||
@@ -112,22 +135,102 @@ If you add a new intent whose trigger words could plausibly appear in an unrelat
|
||||
|
||||
---
|
||||
|
||||
### Customer creation writes to `/admin/customers`
|
||||
### Customer creation writes to `/admin/tenantcustomers`
|
||||
|
||||
`executeCreateCustomer` posts to `/admin/customers` — the same resource the Customers page reads — so a created customer appears there straight away.
|
||||
Settled by evidence, not by reading the docs:
|
||||
|
||||
It previously posted to `/admin/tenantcustomers`. That call succeeded but wrote to a **different store with its own id sequence**: a customer created through the bot came back as id 2 while the Customers list ran to id 44, and never showed on the page. Repointing the *page* at `tenantcustomers` was tried first and reverted — it changes what that page means, and its edit dialog would then PATCH a different store by id (404 at best, editing the wrong person at worst).
|
||||
```
|
||||
POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
```
|
||||
|
||||
Two things to know if this ever needs revisiting:
|
||||
405 is unambiguous — the route exists and POST is not among its methods. `express-console-api.md` lists `/admin/customers` as GET + PATCH only and the server agrees. It was pointed there briefly on explicit instruction; the live 405 settled it. **Don't try it again.**
|
||||
|
||||
- `express-console-api.md` documents `/admin/customers` as **GET + PATCH only**, and describes a customer there as something a booking creates (`POST /admin/expressbooking` — *"creates a Guest customer if unknown"*). The POST was wired on explicit instruction regardless. If the route is absent the server answers 404/405 and the assistant surfaces it verbatim with the endpoint name, so an unsupported route fails loudly rather than silently.
|
||||
- The sidebar's **Create Customer page** (`clients/createCustomer.js`) still calls `createTenantCustomer`. It and the bot now write to different stores. If the bot's endpoint is correct, that page should follow.
|
||||
**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
|
||||
|
||||
**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
|
||||
|
||||
Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
|
||||
|
||||
The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
|
||||
|
||||
Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
|
||||
|
||||
The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
|
||||
|
||||
Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
|
||||
|
||||
**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
|
||||
|
||||
### The panel drives the conversation, not the router
|
||||
|
||||
`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
|
||||
|
||||
Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
|
||||
|
||||
### One engine, three flows
|
||||
|
||||
The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
|
||||
|
||||
Step entries carry:
|
||||
|
||||
| key | meaning |
|
||||
|---|---|
|
||||
| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
|
||||
| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
|
||||
| `type: 'text'` | answered through the composer. |
|
||||
| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
|
||||
| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
|
||||
| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
|
||||
| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
|
||||
| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
|
||||
|
||||
### Two silent-NaN traps that were live
|
||||
|
||||
- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
|
||||
- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
|
||||
|
||||
`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
|
||||
|
||||
### Bulk — a conversation, then one long pass
|
||||
|
||||
Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
|
||||
|
||||
**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
|
||||
|
||||
**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
|
||||
|
||||
**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
|
||||
|
||||
### One row pipeline, two inputs
|
||||
|
||||
A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
|
||||
|
||||
**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
|
||||
|
||||
`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
|
||||
|
||||
**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
|
||||
- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
|
||||
- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
|
||||
- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
|
||||
|
||||
**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
|
||||
|
||||
**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
|
||||
|
||||
Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
|
||||
|
||||
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's deliberately out of scope right now
|
||||
|
||||
- **Write actions.** The original ask included "if I say create an order, it should create it" — deliberately **not built**. Giving a keyword-matched bot the ability to mutate data (order creation has real validation elsewhere: CityGate pincode checks, delivery-slot windows, the dispatch reconcile-before-commit rule) is a materially bigger risk than read-only Q&A. If this gets built, it needs its own guardrail — the bot proposes what it would submit, the operator explicitly confirms, only then does a real create-order call fire. Don't wire a write action straight from intent match to a mutation call.
|
||||
- **Deleting or reassigning anything.** Creates are built (§3.5); destructive and reassigning writes are not, and shouldn't be added without the same conversation. Cancelling an order or swapping a rider has downstream effects (FCM to the rider, the dispatch reconcile rule) that a confirm button doesn't cover.
|
||||
- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives.
|
||||
- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built.
|
||||
- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started.
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
--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.
|
||||
@@ -57,11 +56,9 @@
|
||||
opacity: 0;
|
||||
transition: opacity var(--dai-duration) var(--dai-ease);
|
||||
}
|
||||
|
||||
.dai-scrim[data-open='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Panel
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -87,16 +84,13 @@
|
||||
transform var(--dai-duration) var(--dai-ease),
|
||||
opacity var(--dai-duration) var(--dai-ease);
|
||||
}
|
||||
|
||||
.dai-panel[data-open='true'] {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dai-panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Header
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -105,7 +99,6 @@
|
||||
padding: 14px 12px 12px 14px;
|
||||
border-bottom: 1px solid var(--dai-border);
|
||||
}
|
||||
|
||||
.dai-root .dai-title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
@@ -113,13 +106,11 @@
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dai-text);
|
||||
}
|
||||
|
||||
.dai-root .dai-subtitle {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: var(--dai-text-muted);
|
||||
}
|
||||
|
||||
/* The AI mark. A soft gradient orb — not a robot face. */
|
||||
.dai-root .dai-spark {
|
||||
display: inline-flex;
|
||||
@@ -127,27 +118,34 @@
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
|
||||
box-shadow: 0 0 0 3px var(--dai-ai-glow);
|
||||
background: var(--dai-surface);
|
||||
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: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.dai-root .dai-spark[data-size='md'] {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.dai-root .dai-spark[data-size='lg'] {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
box-shadow: 0 0 0 6px var(--dai-ai-glow);
|
||||
.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;
|
||||
@@ -161,7 +159,6 @@
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dai-root .dai-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -169,7 +166,6 @@
|
||||
background: var(--dai-live);
|
||||
animation: dai-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dai-pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -181,7 +177,6 @@
|
||||
transform: scale(0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page-context strip — "Orders · Today · All locations" */
|
||||
.dai-root .dai-context {
|
||||
flex: 0 0 auto;
|
||||
@@ -194,7 +189,120 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
.dai-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 11px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--dai-text);
|
||||
background: var(--dai-surface);
|
||||
border: 1px solid var(--dai-border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
.dai-suggestion:hover {
|
||||
background: var(--dai-surface-alt);
|
||||
border-color: var(--dai-border-strong);
|
||||
}
|
||||
.dai-suggestion:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.dai-suggestion:focus-visible {
|
||||
outline: 2px solid var(--dai-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dai-root .dai-suggestion-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
background: var(--dai-surface-hover);
|
||||
color: var(--dai-text-secondary);
|
||||
}
|
||||
.dai-root .dai-suggestion-text {
|
||||
flex: 1 1 auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.dai-root .dai-suggestion-arrow {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dai-text-muted);
|
||||
opacity: 0;
|
||||
transform: translateX(-3px);
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
.dai-suggestion:hover .dai-suggestion-arrow,
|
||||
.dai-suggestion:focus-visible .dai-suggestion-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(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
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -225,141 +333,24 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
.dai-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 11px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--dai-text);
|
||||
background: var(--dai-surface);
|
||||
border: 1px solid var(--dai-border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.dai-suggestion:hover {
|
||||
background: var(--dai-surface-alt);
|
||||
border-color: var(--dai-border-strong);
|
||||
}
|
||||
|
||||
.dai-suggestion:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.dai-suggestion:focus-visible {
|
||||
outline: 2px solid var(--dai-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dai-root .dai-suggestion-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
background: var(--dai-surface-hover);
|
||||
color: var(--dai-text-secondary);
|
||||
}
|
||||
|
||||
.dai-root .dai-suggestion-text {
|
||||
.dai-scroll-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dai-root .dai-suggestion-arrow {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dai-text-muted);
|
||||
opacity: 0;
|
||||
transform: translateX(-3px);
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.dai-suggestion:hover .dai-suggestion-arrow,
|
||||
.dai-suggestion:focus-visible .dai-suggestion-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(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
|
||||
-------------------------------------------------------------------------- */
|
||||
.dai-root .dai-thread {
|
||||
padding: 16px 14px 8px;
|
||||
}
|
||||
|
||||
.dai-root .dai-msg {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -374,7 +365,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* User — compact, right aligned, brand surface. */
|
||||
.dai-root .dai-msg-user {
|
||||
max-width: 82%;
|
||||
margin-left: auto;
|
||||
@@ -388,7 +378,6 @@
|
||||
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;
|
||||
@@ -396,36 +385,30 @@
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -435,14 +418,12 @@
|
||||
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;
|
||||
@@ -450,12 +431,10 @@
|
||||
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;
|
||||
@@ -465,12 +444,10 @@
|
||||
color: var(--dai-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dai-root .dai-metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--dai-text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Typing / loading
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -480,7 +457,6 @@
|
||||
gap: 4px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.dai-root .dai-typing span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@@ -488,15 +464,12 @@
|
||||
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%,
|
||||
@@ -509,7 +482,6 @@
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.dai-root .dai-shimmer {
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
@@ -517,7 +489,6 @@
|
||||
background-size: 400% 100%;
|
||||
animation: dai-shimmer 1.5s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes dai-shimmer {
|
||||
from {
|
||||
background-position: 100% 50%;
|
||||
@@ -526,7 +497,6 @@
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
States (error / empty)
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -536,19 +506,16 @@
|
||||
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;
|
||||
@@ -558,7 +525,6 @@
|
||||
border-radius: 5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Jump-to-latest
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -581,24 +547,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.dai-scroll-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Composer
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -608,7 +563,6 @@
|
||||
border-top: 1px solid var(--dai-border);
|
||||
background: var(--dai-surface);
|
||||
}
|
||||
|
||||
.dai-root .dai-composer {
|
||||
border: 1px solid var(--dai-border-strong);
|
||||
border-radius: 5px;
|
||||
@@ -619,12 +573,10 @@
|
||||
border-color 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.dai-root .dai-composer[data-focused='true'] {
|
||||
border-color: var(--dai-accent);
|
||||
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.dai-root .dai-composer textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -641,51 +593,13 @@
|
||||
max-height: 108px; /* ~5 lines */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dai-root .dai-composer textarea::placeholder {
|
||||
color: var(--dai-text-muted);
|
||||
}
|
||||
|
||||
.dai-send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: var(--dai-accent-contrast);
|
||||
background: var(--dai-accent);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.dai-send:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.dai-send:active:not(:disabled) {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
|
||||
.dai-send:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dai-send:focus-visible {
|
||||
outline: 2px solid var(--dai-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dai-root .dai-hint {
|
||||
font-size: 11px;
|
||||
color: var(--dai-text-muted);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Trigger (lives in the app TopNav)
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -693,40 +607,44 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #4f46e5;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.dai-trigger:hover {
|
||||
background: rgba(99, 102, 241, 0.09);
|
||||
border-color: rgba(99, 102, 241, 0.16);
|
||||
/* 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'] {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
|
||||
border-color: transparent;
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Responsive
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -735,24 +653,17 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
.dai-msg-user {
|
||||
max-width: 88%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Reduced motion — no slide, no pulse, no shimmer. Opacity only.
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -761,11 +672,9 @@
|
||||
.dai-scrim {
|
||||
transition: opacity 1ms linear;
|
||||
}
|
||||
|
||||
.dai-panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dai-msg,
|
||||
.dai-live-dot,
|
||||
.dai-typing span,
|
||||
@@ -777,7 +686,6 @@
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Write-action confirm card
|
||||
-------------------------------------------------------------------------- */
|
||||
@@ -787,7 +695,6 @@
|
||||
border-radius: 5px;
|
||||
background: var(--dai-surface-alt);
|
||||
}
|
||||
|
||||
.dai-root .dai-action-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -799,28 +706,22 @@
|
||||
color: #4f46e5;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.dai-root .dai-action-summary {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
font-weight: 550;
|
||||
color: var(--dai-text);
|
||||
}
|
||||
|
||||
.dai-root .dai-action-note {
|
||||
font-size: 12px;
|
||||
color: var(--dai-text-secondary);
|
||||
}
|
||||
|
||||
.dai-root .dai-ok {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.dai-root .dai-err {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Overflow containment
|
||||
--------------------------------------------------------------------------
|
||||
@@ -838,7 +739,6 @@
|
||||
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
|
||||
@@ -850,12 +750,10 @@
|
||||
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 {
|
||||
@@ -863,34 +761,62 @@
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
/* The footer is a baseline-aligned row, not a centred one: the timestamp and
|
||||
the Sources toggle are both small text and must sit on the same line. */
|
||||
/* 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: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
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: 0 1 auto;
|
||||
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 {
|
||||
@@ -901,7 +827,6 @@
|
||||
background: var(--dai-surface-alt);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dai-root .dai-list-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -915,14 +840,12 @@
|
||||
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;
|
||||
@@ -934,11 +857,9 @@
|
||||
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;
|
||||
@@ -946,7 +867,6 @@
|
||||
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
|
||||
@@ -956,7 +876,6 @@
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dai-root .dai-list-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -965,10 +884,36 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
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 '@astryxdesign/core/Button';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
|
||||
// ==============================|| Doormile AI — create-customer form ||============================== //
|
||||
//
|
||||
// Mirrors pages/nearle/clients/createCustomer.js field-for-field, including
|
||||
// the address block, so the assistant asks for the same things the page does.
|
||||
//
|
||||
// Two corrections came out of a real GET /admin/customers response:
|
||||
//
|
||||
// • NO TENANT. The documented POST /admin/tenantcustomers body is
|
||||
// { firstname, lastname, phone, email } and a customer record carries no
|
||||
// tenantid at all. The tenant field this form used to show was speculative
|
||||
// and has been removed.
|
||||
// • The record DOES carry address, doorno, landmark, suburb, city, state,
|
||||
// postcode, latitude and longitude — they exist on the model, contrary to
|
||||
// the "no address field exists" comments in customers.js and
|
||||
// createCustomer.js. They come back empty on every live record, and the
|
||||
// documented create body doesn't list them, so they are sent here on a
|
||||
// best-effort basis: if the server ignores them nothing breaks, and the
|
||||
// first real create will settle whether they persist.
|
||||
//
|
||||
// The form IS the write gate. Every value that will be sent is visible and
|
||||
// editable, and nothing is submitted until Create is pressed.
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
|
||||
// Nominatim results are shaped like Google Places by AddressAutocomplete's
|
||||
// `toPlace`, so this reads the same component types createCustomer.js reads.
|
||||
const fromPlace = (place) => {
|
||||
const out = { latitude: place.geometry?.location?.lat?.(), longitude: place.geometry?.location?.lng?.() };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
const types = c.types || [];
|
||||
if (types.includes('sublocality_level_1') || types.includes('sublocality')) out.suburb = c.long_name;
|
||||
if (types.includes('locality')) out.city = c.long_name;
|
||||
if (types.includes('administrative_area_level_1')) out.state = c.long_name;
|
||||
if (types.includes('postal_code')) out.postcode = c.long_name;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const AICustomerForm = ({ initial, status, error, onSubmit, onCancel }) => {
|
||||
const [values, setValues] = useState({
|
||||
firstname: initial?.firstname || '',
|
||||
lastname: initial?.lastname || '',
|
||||
phone: initial?.phone || '',
|
||||
email: initial?.email || '',
|
||||
address: '',
|
||||
doorno: '',
|
||||
landmark: '',
|
||||
suburb: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
latitude: undefined,
|
||||
longitude: undefined
|
||||
});
|
||||
const [touched, setTouched] = useState({});
|
||||
|
||||
const set = (key) => (v) => setValues((prev) => ({ ...prev, [key]: v }));
|
||||
const blur = (key) => () => setTouched((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
const errors = {
|
||||
firstname: values.firstname.trim() ? null : 'Required',
|
||||
phone: PHONE_RE.test(values.phone.trim()) ? null : 'Enter exactly 10 digits'
|
||||
};
|
||||
const isValid = !errors.firstname && !errors.phone;
|
||||
const isDone = status === 'done';
|
||||
const isBusy = status === 'running';
|
||||
const isLocked = isDone || status === 'cancelled' || status === 'stale';
|
||||
|
||||
if (isLocked) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-action-summary">
|
||||
{[values.firstname, values.lastname].filter(Boolean).join(' ')} · {values.phone}
|
||||
</Text>
|
||||
<Text className={`dai-action-note${isDone ? ' dai-ok' : ''}`}>
|
||||
{isDone ? '✓ Created' : status === 'cancelled' ? 'Cancelled — nothing was sent.' : 'Expired — ask again to recreate this.'}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack className="dai-action dai-form" gap={2} padding={0}>
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput
|
||||
label="First name"
|
||||
size="sm"
|
||||
value={values.firstname}
|
||||
onChange={set('firstname')}
|
||||
onBlur={blur('firstname')}
|
||||
placeholder="Ramesh"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<TextInput label="Last name" size="sm" value={values.lastname} onChange={set('lastname')} placeholder="Kumar" isDisabled={isBusy} />
|
||||
</HStack>
|
||||
{touched.firstname && errors.firstname && <Text className="dai-field-err">First name — {errors.firstname}</Text>}
|
||||
|
||||
<TextInput
|
||||
label="Mobile number"
|
||||
size="sm"
|
||||
value={values.phone}
|
||||
onChange={set('phone')}
|
||||
onBlur={blur('phone')}
|
||||
placeholder="9876543210"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
{touched.phone && errors.phone && <Text className="dai-field-err">Mobile — {errors.phone}</Text>}
|
||||
|
||||
<TextInput
|
||||
label="Email (optional)"
|
||||
size="sm"
|
||||
value={values.email}
|
||||
onChange={set('email')}
|
||||
placeholder="ramesh@example.com"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
|
||||
{/* Address search fills suburb / city / state / postcode and the
|
||||
coordinates in one go, the same way the create-customer page does. */}
|
||||
<AddressAutocomplete
|
||||
label="Address"
|
||||
placeholder="Search address"
|
||||
value={values.address}
|
||||
onChange={set('address')}
|
||||
onPlaceSelected={(place) =>
|
||||
setValues((prev) => ({ ...prev, address: place.formatted_address || prev.address, ...fromPlace(place) }))
|
||||
}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput label="Door no" size="sm" value={values.doorno} onChange={set('doorno')} placeholder="12A" isDisabled={isBusy} />
|
||||
<TextInput
|
||||
label="Landmark"
|
||||
size="sm"
|
||||
value={values.landmark}
|
||||
onChange={set('landmark')}
|
||||
placeholder="Near the temple"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput label="Suburb" size="sm" value={values.suburb} onChange={set('suburb')} placeholder="Gandhipuram" isDisabled={isBusy} />
|
||||
<TextInput label="City" size="sm" value={values.city} onChange={set('city')} placeholder="Coimbatore" isDisabled={isBusy} />
|
||||
</HStack>
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput label="State" size="sm" value={values.state} onChange={set('state')} placeholder="Tamil Nadu" isDisabled={isBusy} />
|
||||
<TextInput label="Postcode" size="sm" value={values.postcode} onChange={set('postcode')} placeholder="641012" isDisabled={isBusy} />
|
||||
</HStack>
|
||||
|
||||
<Text className="dai-action-note">
|
||||
Name and mobile are required. The create endpoint documents only name, phone and email — the address is sent too, but may not be
|
||||
stored.
|
||||
</Text>
|
||||
|
||||
{status === 'failed' && error && <Text className="dai-field-err">{error}</Text>}
|
||||
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={isBusy ? 'Creating…' : 'Create customer'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!isValid || isBusy}
|
||||
onClick={() => onSubmit(values)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AICustomerForm.propTypes = {
|
||||
initial: PropTypes.object,
|
||||
status: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AICustomerForm;
|
||||
113
src/pages/nearle/assistant/DoormileAI/AIFlowStep.js
Normal file
113
src/pages/nearle/assistant/DoormileAI/AIFlowStep.js
Normal file
@@ -0,0 +1,113 @@
|
||||
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 '@astryxdesign/core/Button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
|
||||
// ==============================|| Doormile AI — one dropdown step ||============================== //
|
||||
//
|
||||
// Rendered when a conversational step is a `select`. The Create Order page uses
|
||||
// dropdowns for business location, category, weight and service tier, so the
|
||||
// bot does too — asking an operator to TYPE a location name they'd otherwise
|
||||
// pick from a list is slower and invites a name the resolver can't match.
|
||||
//
|
||||
// Options load asynchronously (locations, customers and tenants are fetched
|
||||
// live) so the list is never stale or invented. Two states matter and are
|
||||
// distinguished rather than merged into one spinner:
|
||||
//
|
||||
// • loading — still fetching
|
||||
// • empty — fetched successfully and there genuinely is nothing to pick,
|
||||
// which is a dead end the operator has to be told about (a
|
||||
// tenant with no saved locations cannot have an order created)
|
||||
const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
|
||||
const [options, setOptions] = useState(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setOptions(null);
|
||||
setFailed(false);
|
||||
setValue('');
|
||||
Promise.resolve(step.options?.(draft) ?? [])
|
||||
.then((list) => alive && setOptions(list))
|
||||
.catch(() => alive && setFailed(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step.id]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-field-err">I couldn’t load the options for that. Say “cancel” and try again.</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (options === null) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-action-note">Loading options…</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<Text className="dai-field-err">
|
||||
{step.id === 'pickuplocationid'
|
||||
? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.'
|
||||
: 'There’s nothing to choose from here.'}
|
||||
</Text>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = options.find((o) => String(o.value) === String(value));
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<Selector
|
||||
label={step.ask}
|
||||
isLabelHidden
|
||||
placeholder="Choose one"
|
||||
options={options.map((o) => ({ value: String(o.value), label: o.label }))}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
// A long customer list is unusable without search.
|
||||
hasSearch={options.length > 8}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label="Continue"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!value || isBusy}
|
||||
// The whole option is passed back, not just its value — steps that
|
||||
// pick a record (a location, a customer) need the record itself to
|
||||
// fill several fields at once.
|
||||
onClick={() => onChoose(value, chosen)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIFlowStep.propTypes = {
|
||||
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string, options: PropTypes.func }).isRequired,
|
||||
onChoose: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
draft: PropTypes.object,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIFlowStep;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
|
||||
@@ -7,11 +7,13 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { ChatToolCalls } from '@astryxdesign/core/Chat';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AICustomerForm from './AICustomerForm';
|
||||
import AIOrderForm from './AIOrderForm';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
import AIRowsStep from './AIRowsStep';
|
||||
import { failedRowsCsv, downloadCsv } from '../bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — a single turn ||============================== //
|
||||
//
|
||||
@@ -32,7 +34,7 @@ const UserMessage = ({ text }) => (
|
||||
|
||||
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
||||
|
||||
const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction }) => {
|
||||
const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
|
||||
const [showSources, setShowSources] = useState(false);
|
||||
const sourceCount = message.sourceCalls?.length || 0;
|
||||
|
||||
@@ -71,7 +73,12 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
||||
|
||||
{message.list && <AnswerList title={message.list.title} items={message.list.items} numbered={message.list.numbered !== false} />}
|
||||
|
||||
{message.detail && <Text className="dai-msg-ai-detail">{message.detail}</Text>}
|
||||
{message.detail &&
|
||||
(message.isDoc ? (
|
||||
<Text className="dai-doc">{message.detail}</Text>
|
||||
) : (
|
||||
<Text className="dai-msg-ai-detail">{message.detail}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -81,13 +88,15 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
||||
<VStack gap={1} padding={0}>
|
||||
<HStack className="dai-msg-foot" gap={0} padding={0}>
|
||||
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
|
||||
<button type="button" className="dai-link" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
<LuChevronDown
|
||||
size={11}
|
||||
style={{ marginLeft: 3, verticalAlign: '-1px', transform: showSources ? 'rotate(180deg)' : 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* The chevron's offset and rotation live in the stylesheet — as inline
|
||||
styles they couldn't be transitioned, and `verticalAlign` only
|
||||
works while the button's content is inline, which is exactly the
|
||||
thing that made it wrap. */}
|
||||
<button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<LuChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
@@ -100,25 +109,75 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
||||
localStorage in a later session is marked 'stale' by the panel and
|
||||
renders read-only, so a write can never fire from a resurrected
|
||||
message. */}
|
||||
{message.form?.kind === 'createOrder' && (
|
||||
<AIOrderForm
|
||||
status={message.form.status}
|
||||
error={message.form.error}
|
||||
onSubmit={(values) => onSubmitForm(message, values)}
|
||||
{/* A conversational step that needs a dropdown rather than typing. */}
|
||||
{message.liveStop && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* The bulk create's rows turn — a whole file in one answer. */}
|
||||
{message.flowStep?.type === 'rows' && !message.flowStep.__answered && (
|
||||
<AIRowsStep
|
||||
isBusy={message.flowStep.__answered}
|
||||
onSubmit={(parsed) => onChooseStep(message, parsed)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.form?.kind === 'createCustomer' && (
|
||||
<AICustomerForm
|
||||
initial={message.form.initial}
|
||||
status={message.form.status}
|
||||
error={message.form.error}
|
||||
onSubmit={(values) => onSubmitForm(message, values)}
|
||||
{message.flowStep?.type === 'select' && (
|
||||
<AIFlowStep
|
||||
step={message.flowStep}
|
||||
draft={message.flowDraft}
|
||||
isBusy={message.flowStep.__answered}
|
||||
onChoose={(value, option) => onChooseStep(message, value, option)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(message.form?.kind === 'confirmCustomer' || message.form?.kind === 'confirmOrder' || message.form?.kind === 'confirmBulk') && (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{message.form.status === 'open' ? (
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={
|
||||
message.form.kind === 'confirmBulk'
|
||||
? `Create ${message.form.draft?.rows?.length || 0} orders`
|
||||
: message.form.kind === 'confirmOrder'
|
||||
? 'Create order'
|
||||
: 'Submit'
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
|
||||
</HStack>
|
||||
) : (
|
||||
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>
|
||||
{message.form.status === 'done'
|
||||
? '✓ Created'
|
||||
: message.form.status === 'cancelled'
|
||||
? 'Cancelled — nothing was sent.'
|
||||
: message.form.status === 'failed'
|
||||
? message.form.error
|
||||
: 'Expired — ask again to recreate this.'}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{message.downloadRows?.length > 0 && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button
|
||||
label={`Download the ${message.downloadRows.length} row${message.downloadRows.length === 1 ? '' : 's'} that didn’t go through`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-unsendable.csv', failedRowsCsv(message.downloadRows))}
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{message.followUps?.length > 0 && (
|
||||
<VStack gap={1} padding={0}>
|
||||
{message.followUps.map((q) => (
|
||||
@@ -138,14 +197,24 @@ AssistantMessage.propTypes = {
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onAsk: PropTypes.func.isRequired,
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction }) =>
|
||||
const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
|
||||
message.sender === 'user' ? (
|
||||
<UserMessage text={message.text} />
|
||||
) : (
|
||||
<AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} onSubmitForm={onSubmitForm} onCancelAction={onCancelAction} />
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
onCopy={onCopy}
|
||||
onAsk={onAsk}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onCancelAction={onCancelAction}
|
||||
onChooseStep={onChooseStep}
|
||||
onStopLive={onStopLive}
|
||||
/>
|
||||
);
|
||||
|
||||
AIMessage.propTypes = {
|
||||
@@ -153,7 +222,14 @@ AIMessage.propTypes = {
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onAsk: PropTypes.func.isRequired,
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AIMessage;
|
||||
// Memoised, and this is not an optimisation garnish — it's what makes the
|
||||
// thread scrollable. Every scroll event and every composer keystroke re-renders
|
||||
// the panel, and without this each one re-rendered all 50 restored turns,
|
||||
// tool-call disclosures included. The panel keeps its handler identities stable
|
||||
// so this comparison actually holds.
|
||||
export default memo(AIMessage);
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import { useEffect, useMemo, 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 '@astryxdesign/core/Button';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor, loadOrderTenants, loadPickupLocations, validateOrderDraft } from '../orderActions';
|
||||
|
||||
// ==============================|| Doormile AI — create-order form ||============================== //
|
||||
//
|
||||
// Pickup is a single dropdown, not six fields: `pickuplocationid` names a
|
||||
// stored tenant location and the server fills address, pincode and coordinates
|
||||
// from it. That also sidesteps CityGate, since a saved location has already
|
||||
// passed it.
|
||||
//
|
||||
// Delivery still needs a real address, and specifically real coordinates —
|
||||
// the dispatch optimiser routes on them. The form therefore refuses to submit
|
||||
// until an address has been PICKED from the suggestions, because typing text
|
||||
// into the box yields no lat/lng and would create a booking that can never be
|
||||
// dispatched.
|
||||
|
||||
const EMPTY = {
|
||||
tenantid: '',
|
||||
pickuplocationid: '',
|
||||
customer_name: '',
|
||||
customer_phone: '',
|
||||
deliveryaddress: '',
|
||||
deliverypincode: '',
|
||||
deliverycity: '',
|
||||
deliverylatitude: undefined,
|
||||
deliverylongitude: undefined,
|
||||
service_option: 'Normal',
|
||||
finalprice: '',
|
||||
itemcategory: 'General',
|
||||
itemdescription: '',
|
||||
declaredvalue: '',
|
||||
quantity: '1',
|
||||
notes: ''
|
||||
};
|
||||
|
||||
const fromPlace = (place) => {
|
||||
const out = {
|
||||
deliveryaddress: place.formatted_address || '',
|
||||
deliverylatitude: place.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place.geometry?.location?.lng?.()
|
||||
};
|
||||
(place.address_components || []).forEach((c) => {
|
||||
const types = c.types || [];
|
||||
if (types.includes('locality')) out.deliverycity = c.long_name;
|
||||
if (types.includes('postal_code')) out.deliverypincode = c.long_name;
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const AIOrderForm = ({ status, error, onSubmit, onCancel }) => {
|
||||
const [values, setValues] = useState(EMPTY);
|
||||
const [tenants, setTenants] = useState([]);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [loadingLocations, setLoadingLocations] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const set = (key) => (v) => setValues((prev) => ({ ...prev, [key]: v }));
|
||||
|
||||
const isBusy = status === 'running';
|
||||
const isDone = status === 'done';
|
||||
const isLocked = isDone || status === 'cancelled' || status === 'stale';
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadOrderTenants().then((list) => alive && setTenants(list));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pickup locations are tenant-scoped; a location belonging to another tenant
|
||||
// is rejected server-side, so the list is reloaded whenever the tenant changes
|
||||
// and the previous choice is cleared rather than carried over.
|
||||
useEffect(() => {
|
||||
if (!values.tenantid) {
|
||||
setLocations([]);
|
||||
return undefined;
|
||||
}
|
||||
let alive = true;
|
||||
setLoadingLocations(true);
|
||||
loadPickupLocations(values.tenantid)
|
||||
.then((list) => alive && setLocations(list || []))
|
||||
.finally(() => alive && setLoadingLocations(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [values.tenantid]);
|
||||
|
||||
const { ok, errors } = validateOrderDraft(values);
|
||||
const pickup = useMemo(
|
||||
() => locations.find((l) => String(l.locationid) === String(values.pickuplocationid)),
|
||||
[locations, values.pickuplocationid]
|
||||
);
|
||||
const pickupCity = pickup ? cityGateFor(pickup.pincode) : null;
|
||||
|
||||
if (isLocked) {
|
||||
return (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Text className="dai-action-summary">
|
||||
{values.customer_name || 'Order'} · {values.customer_phone}
|
||||
</Text>
|
||||
<Text className={`dai-action-note${isDone ? ' dai-ok' : ''}`}>
|
||||
{isDone ? '✓ Created' : status === 'cancelled' ? 'Cancelled — nothing was sent.' : 'Expired — ask again to recreate this.'}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const err = (key) => touched && errors[key] && <Text className="dai-field-err">{errors[key]}</Text>;
|
||||
|
||||
return (
|
||||
<VStack className="dai-action dai-form" gap={2} padding={0}>
|
||||
<Selector
|
||||
label="Tenant"
|
||||
placeholder="Choose a tenant"
|
||||
options={tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }))}
|
||||
value={String(values.tenantid || '')}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, tenantid: v, pickuplocationid: '' }))}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
{err('tenantid')}
|
||||
|
||||
<Selector
|
||||
label="Pickup location"
|
||||
placeholder={values.tenantid ? 'Choose a pickup location' : 'Choose a tenant first'}
|
||||
options={locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
label: `${l.locationname || 'Location'}${l.pincode ? ` · ${l.pincode}` : ''}`
|
||||
}))}
|
||||
value={String(values.pickuplocationid || '')}
|
||||
onChange={set('pickuplocationid')}
|
||||
isLoading={loadingLocations}
|
||||
isDisabled={isBusy || !values.tenantid}
|
||||
/>
|
||||
{err('pickuplocationid')}
|
||||
{pickup && (
|
||||
<Text className="dai-action-note">
|
||||
{pickup.address}
|
||||
{pickupCity ? ` · ${pickupCity}` : ' · pincode outside the open cities — this may be refused'}
|
||||
</Text>
|
||||
)}
|
||||
{values.tenantid && !loadingLocations && locations.length === 0 && (
|
||||
<Text className="dai-field-err">This tenant has no saved pickup locations. Add one on the Tenants page first.</Text>
|
||||
)}
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput
|
||||
label="Customer name"
|
||||
size="sm"
|
||||
value={values.customer_name}
|
||||
onChange={set('customer_name')}
|
||||
placeholder="Ramesh"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<TextInput
|
||||
label="Mobile"
|
||||
size="sm"
|
||||
value={values.customer_phone}
|
||||
onChange={set('customer_phone')}
|
||||
placeholder="9876543210"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
</HStack>
|
||||
{err('customer_name')}
|
||||
{err('customer_phone')}
|
||||
|
||||
<AddressAutocomplete
|
||||
label="Delivery address"
|
||||
placeholder="Search the delivery address"
|
||||
value={values.deliveryaddress}
|
||||
onChange={set('deliveryaddress')}
|
||||
onPlaceSelected={(place) => setValues((prev) => ({ ...prev, ...fromPlace(place) }))}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{err('deliveryaddress')}
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput
|
||||
label="Delivery pincode"
|
||||
size="sm"
|
||||
value={values.deliverypincode}
|
||||
onChange={set('deliverypincode')}
|
||||
placeholder="641012"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<TextInput
|
||||
label="City"
|
||||
size="sm"
|
||||
value={values.deliverycity}
|
||||
onChange={set('deliverycity')}
|
||||
placeholder="Coimbatore"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
</HStack>
|
||||
{err('deliverypincode')}
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<Selector
|
||||
label="Service"
|
||||
options={SERVICE_OPTIONS.map((o) => ({ value: o, label: o }))}
|
||||
value={values.service_option}
|
||||
onChange={set('service_option')}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<TextInput label="Price" size="sm" value={values.finalprice} onChange={set('finalprice')} placeholder="120" isDisabled={isBusy} />
|
||||
</HStack>
|
||||
{err('finalprice')}
|
||||
|
||||
<TextInput
|
||||
label="Parcel description"
|
||||
size="sm"
|
||||
value={values.itemdescription}
|
||||
onChange={set('itemdescription')}
|
||||
placeholder="2 meal boxes"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
{err('itemdescription')}
|
||||
|
||||
<HStack className="dai-form-row" gap={0} padding={0}>
|
||||
<TextInput
|
||||
label="Declared value"
|
||||
size="sm"
|
||||
value={values.declaredvalue}
|
||||
onChange={set('declaredvalue')}
|
||||
placeholder="350"
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<TextInput label="Quantity" size="sm" value={values.quantity} onChange={set('quantity')} placeholder="1" isDisabled={isBusy} />
|
||||
<TextInput label="Notes" size="sm" value={values.notes} onChange={set('notes')} placeholder="Ring the bell" isDisabled={isBusy} />
|
||||
</HStack>
|
||||
|
||||
<Text className="dai-action-note">
|
||||
Pickup address, pincode and coordinates come from the saved location. Delivery coordinates come from the address search — pick a
|
||||
suggestion rather than typing, or the order can’t be routed.
|
||||
</Text>
|
||||
|
||||
{status === 'failed' && error && <Text className="dai-field-err">{error}</Text>}
|
||||
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={isBusy ? 'Creating…' : 'Create order'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!ok || isBusy}
|
||||
onClick={() => {
|
||||
setTouched(true);
|
||||
// __pickup carries the saved location itself, not just its id —
|
||||
// the payload copies its address/pincode/coords onto the booking.
|
||||
if (ok) onSubmit({ ...values, __pickup: pickup });
|
||||
}}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
</HStack>
|
||||
{!ok && touched && <Text className="dai-field-err">Fill in the highlighted fields before creating.</Text>}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIOrderForm.propTypes = {
|
||||
status: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AIOrderForm;
|
||||
@@ -16,7 +16,11 @@ import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents';
|
||||
import { executeCreateCustomer, buildCustomerPayload } from '../actions';
|
||||
import { executeCreateOrder, buildOrderPayload } from '../orderActions';
|
||||
import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '../orderActions';
|
||||
import { executeCreateBulk } from '../bulkOrderActions';
|
||||
import { startCustomerFlow, answerStep } from '../customerFlow';
|
||||
import { startOrderFlow, answerOrderStep } from '../orderFlow';
|
||||
import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '../bulkFlow';
|
||||
import { getPageContext } from './pageContext';
|
||||
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
|
||||
import AIWelcome from './AIWelcome';
|
||||
@@ -53,7 +57,10 @@ const loadHistory = () => {
|
||||
// last closed is retired on load. Otherwise a Create button could sit in
|
||||
// restored history for days and fire against data the operator can no
|
||||
// longer see — the proposal must be regenerated against current state.
|
||||
return parsed.map((m) => (m.form?.status === 'open' ? { ...m, form: { ...m.form, status: 'stale' } } : m));
|
||||
return parsed.map((m) => {
|
||||
const next = m.flowStep ? { ...m, flowStep: undefined } : m;
|
||||
return next.form?.status === 'open' ? { ...next, form: { ...next.form, status: 'stale' } } : next;
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -78,10 +85,25 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
|
||||
const [isPinned, setIsPinned] = useState(true);
|
||||
// An in-progress conversational create. Held in memory only — never
|
||||
// persisted, so a half-finished flow can't be resurrected in a later session
|
||||
// and submitted against stale intent.
|
||||
const [flow, setFlow] = useState(null);
|
||||
|
||||
const panelRef = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
const lastQuestionRef = useRef(null);
|
||||
// Mirrors isPinned so the throttled scroll handler can compare without
|
||||
// re-subscribing, and cancels an in-flight frame on unmount.
|
||||
const pinnedRef = useRef(true);
|
||||
// Stop is a ref, never state: read inside the async pass, a state value is
|
||||
// captured at call time and never updates, so Stop did nothing. The geocode
|
||||
// cache is a ref so re-running after a fix doesn't re-look-up what's known.
|
||||
const bulkStop = useRef(false);
|
||||
const bulkCache = useRef(new Map());
|
||||
const rafRef = useRef(0);
|
||||
|
||||
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
|
||||
|
||||
// ---- open / close transition ----
|
||||
useEffect(() => {
|
||||
@@ -126,19 +148,209 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, isSending, isPinned]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setIsPinned(el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX);
|
||||
};
|
||||
// A scroll event fires several times per frame, and this used to call
|
||||
// setIsPinned on every one of them — re-rendering the entire thread mid-scroll.
|
||||
// The measurement is now rAF-throttled and the state is written only when the
|
||||
// flag actually flips, so ordinary scrolling costs no renders at all.
|
||||
const handleScroll = useCallback(() => {
|
||||
if (rafRef.current) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = 0;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const pinned = el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
|
||||
if (pinned === pinnedRef.current) return;
|
||||
pinnedRef.current = pinned;
|
||||
setIsPinned(pinned);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollToLatest = () => {
|
||||
const scrollToLatest = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
pinnedRef.current = true;
|
||||
setIsPinned(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const push = (message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]);
|
||||
// Every handler passed to AIMessage is stabilised, because a new function
|
||||
// identity on each render defeats its memo and puts all 50 turns back on the
|
||||
// critical path of a scroll.
|
||||
const push = useCallback((message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]), []);
|
||||
|
||||
// Pushes a message and hands back its id, so a long-running pass can rewrite
|
||||
// that one turn rather than pushing a new one per row.
|
||||
const pushLive = useCallback((message) => {
|
||||
const id = makeId();
|
||||
setMessages((prev) => [...prev, { id, timestamp: now(), ...message }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const patch = useCallback((id, fields) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, ...fields } : m))), []);
|
||||
|
||||
// Renders whatever a flow step needs: a dropdown, a typed question, or the
|
||||
// finished quote. Kept in one place so the start of a flow and every
|
||||
// subsequent answer produce identical-looking turns.
|
||||
// Renders one turn of ANY conversational create. The question half is shared;
|
||||
// what happens at the end is the caller's (finishOrder / finishBulk).
|
||||
const renderFlowStep = useCallback(
|
||||
async (next, onDone) => {
|
||||
if (!next.done) {
|
||||
if (next.retry) {
|
||||
push({ sender: 'assistant', text: next.ask });
|
||||
return;
|
||||
}
|
||||
const step = next.step;
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: next.ask || step.ask,
|
||||
// A `select` or `rows` step carries the step definition so AIMessage
|
||||
// can render its control; a `text` step is answered in the composer.
|
||||
flowStep: step.type === 'select' || step.type === 'rows' ? step : undefined,
|
||||
flowDraft: next.flow.draft
|
||||
});
|
||||
return;
|
||||
}
|
||||
await onDone(next.draft);
|
||||
},
|
||||
[push]
|
||||
);
|
||||
|
||||
// The single order's completion: quote, gate, then offer one press.
|
||||
const finishOrder = useCallback(
|
||||
async (draft) => {
|
||||
// Every field gathered, price included — the flow itself quotes it and asks
|
||||
// for the amount when it can't (see orderFlow.js's `finalprice` step), so
|
||||
// there is never a NaN or an invented zero on the wire.
|
||||
const quote = draft.__quote || {};
|
||||
|
||||
// Last gate before a Create button exists. The flow validates field by
|
||||
// field, but this is the same check the Create Order page runs on the whole
|
||||
// record — if it fails, the operator gets told what's missing instead of a
|
||||
// button that would post a rejected booking.
|
||||
const { ok, errors } = validateOrderDraft(draft);
|
||||
if (!ok) {
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: {
|
||||
tone: STATUS.error,
|
||||
iconKey: 'warning',
|
||||
title: "This order isn't complete",
|
||||
body: `${Object.values(errors).join(' · ')}. Say “create an order” to start again.`
|
||||
}
|
||||
});
|
||||
setFlow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `That comes to ₹${Number(draft.finalprice).toFixed(2)}.`,
|
||||
list: {
|
||||
title: 'Will be sent',
|
||||
numbered: false,
|
||||
items: [
|
||||
{ label: 'Customer', meta: `${draft.customer_name} · ${draft.customer_phone}` },
|
||||
{ label: 'Pickup', meta: draft.__pickup?.locationname || draft.__pickup?.address || '—' },
|
||||
{ label: 'Drop', meta: draft.deliveryaddress },
|
||||
{ label: 'Service', meta: draft.service_option },
|
||||
{ label: 'Parcels', meta: `${draft.quantity} · ${draft.itemcategory} · ${draft.itemdescription}` },
|
||||
quote.km != null
|
||||
? { label: 'Distance', meta: `${quote.km} km${quote.durationMin ? ` · ${quote.durationMin} min` : ''}` }
|
||||
: null,
|
||||
{ label: 'Price', meta: `₹${Number(draft.finalprice).toFixed(2)}` }
|
||||
].filter(Boolean)
|
||||
},
|
||||
// Where the number came from matters: a quoted price is reproducible from
|
||||
// the tenant's pricing row, an entered one is the operator's own.
|
||||
detail:
|
||||
quote.total != null
|
||||
? 'Priced from this tenant’s pricing row and the routed distance.'
|
||||
: 'Price entered manually — it couldn’t be quoted automatically.',
|
||||
form: { kind: 'confirmOrder', status: 'open', draft }
|
||||
});
|
||||
},
|
||||
[push]
|
||||
);
|
||||
|
||||
// Every field gathered — now the long pass. Locating is ~1 lookup/second, so
|
||||
// it is narrated into a single message the operator can watch and stop,
|
||||
// rather than freezing silently for minutes.
|
||||
const finishBulk = useCallback(
|
||||
async (draft) => {
|
||||
const rows = draft.rows;
|
||||
const needed = lookupsNeeded(rows, bulkCache.current);
|
||||
bulkStop.current = false;
|
||||
|
||||
const liveId = pushLive({
|
||||
sender: 'assistant',
|
||||
text: needed
|
||||
? `Locating ${needed} address${needed === 1 ? '' : 'es'} — about ${Math.ceil(
|
||||
(needed * GEOCODE_INTERVAL_MS) / 1000
|
||||
)}s. The address service allows roughly one per second.`
|
||||
: 'Checking the rows…',
|
||||
// Rendered as a plain turn with a Stop button; see AIMessage's liveStop.
|
||||
liveStop: true
|
||||
});
|
||||
|
||||
const result = await resolveBulkRows(rows, {
|
||||
pickup: draft.__pickup,
|
||||
tenantid: draft.tenantid || localStorage.getItem('tenantid'),
|
||||
cache: bulkCache.current,
|
||||
shouldStop: () => bulkStop.current,
|
||||
onProgress: (p) =>
|
||||
patch(liveId, {
|
||||
text: `${p.phase === 'price' ? 'Pricing' : 'Locating addresses'}… ${Math.min(p.done + 1, p.total)} of ${p.total}`,
|
||||
detail: p.current || undefined
|
||||
})
|
||||
});
|
||||
|
||||
patch(liveId, { text: `Checked ${rows.length} row${rows.length === 1 ? '' : 's'}.`, detail: undefined, liveStop: false });
|
||||
|
||||
if (!result.valid.length) {
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: {
|
||||
tone: STATUS.error,
|
||||
iconKey: 'warning',
|
||||
title: 'None of these rows can be sent',
|
||||
body: result.invalid[0]?.error
|
||||
? `The first problem is: ${result.invalid[0].error}. Fix the rows and say “bulk upload orders” again.`
|
||||
: 'Fix the rows and try again.'
|
||||
}
|
||||
});
|
||||
setFlow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const batches = batchCount(result.valid.length);
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `${result.valid.length} order${result.valid.length === 1 ? '' : 's'} ready to create.`,
|
||||
list: {
|
||||
title: 'Will be sent',
|
||||
numbered: false,
|
||||
// Capped for readability, and the cap is STATED — a list that silently
|
||||
// showed 12 of 200 would read as the whole file.
|
||||
items: result.valid.slice(0, 12).map((r) => ({
|
||||
label: `${r.customer_name} · ${r.customer_phone}`,
|
||||
meta: `₹${Number(r.finalprice).toFixed(2)}${r.quoted ? ' quoted' : ''} · ${r.resolvedAddress || r.deliveryaddress}`
|
||||
}))
|
||||
},
|
||||
detail: [
|
||||
result.valid.length > 12 ? `Showing the first 12 of ${result.valid.length}.` : null,
|
||||
result.invalid.length
|
||||
? `${result.invalid.length} row${result.invalid.length === 1 ? '' : 's'} cannot be sent and will be left out.`
|
||||
: null,
|
||||
batches > 1 ? `Sent in ${batches} batches of at most 200.` : null,
|
||||
`Pickup: ${draft.__pickup?.locationname || draft.__pickup?.address || '—'} · ${draft.service_option}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
form: { kind: 'confirmBulk', status: 'open', draft: { ...draft, rows: result.valid, invalid: result.invalid } }
|
||||
});
|
||||
},
|
||||
[push, pushLive, patch]
|
||||
);
|
||||
|
||||
const ask = useCallback(
|
||||
async (text) => {
|
||||
@@ -152,14 +364,111 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
// ---- an answer to a question we asked ----------------------------
|
||||
//
|
||||
// Checked BEFORE the router. This is the whole fix: answerQuestion
|
||||
// picks an intent by matching text, so a bare reply like
|
||||
// "8494948494" matches nothing and used to be swallowed by the
|
||||
// "I can't answer that yet" fallback. Mid-flow, the text is an
|
||||
// answer — not a question — and is never routed.
|
||||
if (flow && !flow.complete) {
|
||||
if (/^(?:cancel|stop|nevermind|never mind|forget it)$/i.test(question)) {
|
||||
setFlow(null);
|
||||
push({ sender: 'assistant', text: 'Cancelled — nothing was created.' });
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// An order flow answers through this same path when the step is
|
||||
// free text; `select` steps come in through chooseStep instead.
|
||||
if (flow.kind === 'createBulk') {
|
||||
const next = await answerBulkStep(flow, question);
|
||||
setFlow(next.flow);
|
||||
await renderFlowStep(next, finishBulk);
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow.kind === 'createOrder') {
|
||||
const next = await answerOrderStep(flow, question);
|
||||
setFlow(next.flow);
|
||||
await renderFlowStep(next, finishOrder);
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = answerStep(flow, question);
|
||||
setFlow(next.flow);
|
||||
|
||||
if (next.done) {
|
||||
// Every field gathered. Show exactly what will be sent and wait
|
||||
// for Submit — the same write gate every other create uses.
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: 'Ready to create this customer.',
|
||||
list: {
|
||||
title: 'Will be sent',
|
||||
numbered: false,
|
||||
items: Object.entries(next.payload).map(([k, v]) => ({ label: k, meta: String(v) }))
|
||||
},
|
||||
form: { kind: 'confirmCustomer', status: 'open', payload: next.payload }
|
||||
});
|
||||
} else {
|
||||
push({ sender: 'assistant', text: next.ask });
|
||||
}
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await answerQuestion(question, context);
|
||||
if (result) {
|
||||
setContext({ lastIntentId: result.intentId, lastParams: result.params });
|
||||
// createCustomer no longer opens a form — it starts a conversation.
|
||||
// The form version was replaced on explicit request; asking one
|
||||
// question at a time reads better and matches how an operator would
|
||||
// dictate a record.
|
||||
if (result.form?.kind === 'createBulkOrders') {
|
||||
const started = await startBulkFlow();
|
||||
setFlow(started.flow);
|
||||
await renderFlowStep(started, finishBulk);
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.form?.kind === 'createOrder') {
|
||||
const started = await startOrderFlow();
|
||||
setFlow(started.flow);
|
||||
await renderFlowStep(started, finishOrder);
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.form?.kind === 'createCustomer') {
|
||||
const started = startCustomerFlow(question);
|
||||
setFlow(started.flow);
|
||||
push({ sender: 'assistant', text: started.done ? 'Ready to create this customer.' : started.ask });
|
||||
if (started.done) {
|
||||
push({
|
||||
sender: 'assistant',
|
||||
list: {
|
||||
title: 'Will be sent',
|
||||
numbered: false,
|
||||
items: Object.entries(started.payload).map(([k, v]) => ({ label: k, meta: String(v) }))
|
||||
},
|
||||
form: { kind: 'confirmCustomer', status: 'open', payload: started.payload }
|
||||
});
|
||||
}
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
push({
|
||||
form: result.form,
|
||||
sender: 'assistant',
|
||||
text: result.headline,
|
||||
detail: result.detail,
|
||||
// Documentation answers read as prose, not as a figure.
|
||||
isDoc: result.intentId === 'docsAnswer',
|
||||
metric: result.metric,
|
||||
stats: result.stats,
|
||||
sourceCalls: result.sourceCalls,
|
||||
@@ -168,13 +477,18 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
} else {
|
||||
// Matched nothing. This is a coverage state, not a failure — say so
|
||||
// plainly rather than inventing an answer.
|
||||
// Genuinely off-topic questions land here. Saying only "I can't
|
||||
// answer that" leaves an operator with nowhere to go, so this points
|
||||
// at the two real places an answer might exist: rephrasing around
|
||||
// the data this console holds, or doormile.com for anything about
|
||||
// Doormile itself, which this app has no material on.
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: {
|
||||
tone: STATUS.muted,
|
||||
iconKey: 'info',
|
||||
title: "I can't answer that one yet",
|
||||
body: 'Try rephrasing around orders, riders, hubs, vehicles, batches, status, tenants or revenue — or pick one of the suggestions.'
|
||||
title: "I can't answer that one",
|
||||
body: 'I only read this console’s live operations — orders, riders, hubs, vehicles, batches, tenants and revenue. Try rephrasing around those, or see doormile.com for anything about Doormile itself.'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -194,105 +508,244 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[context, isSending]
|
||||
[context, isSending, flow, renderFlowStep, finishOrder, finishBulk, push]
|
||||
);
|
||||
|
||||
const copyMessage = (m) => {
|
||||
const copyMessage = useCallback((m) => {
|
||||
const text = [m.text, m.detail].filter(Boolean).join('\n');
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => OpenToast('Copied to clipboard', 'success', 1500))
|
||||
.catch(() => OpenToast('Could not copy', 'error', 1500));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearConversation = () => {
|
||||
const clearConversation = useCallback(() => {
|
||||
setMessages([]);
|
||||
setContext({ lastIntentId: null, lastParams: null });
|
||||
setFlow(null);
|
||||
pinnedRef.current = true;
|
||||
setIsPinned(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setFormStatus = (id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m)));
|
||||
const setFormStatus = useCallback(
|
||||
(id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m))),
|
||||
[]
|
||||
);
|
||||
|
||||
// The single place a write leaves the assistant. Reached only by pressing
|
||||
// Create on an open form.
|
||||
const submitForm = async (message, values) => {
|
||||
if (message.form?.status !== 'open') return;
|
||||
setFormStatus(message.id, { status: 'running', error: undefined, initial: values });
|
||||
const submitForm = useCallback(
|
||||
async (message, values) => {
|
||||
if (message.form?.status !== 'open') return;
|
||||
setFormStatus(message.id, { status: 'running', error: undefined, initial: values });
|
||||
|
||||
// ---- order ----
|
||||
if (message.form.kind === 'createOrder') {
|
||||
// The saved location the operator chose — its address/pincode/coords go
|
||||
// on the booking, because pickuplocationid 500s server-side.
|
||||
const payload = buildOrderPayload(values, values.__pickup);
|
||||
const res = await executeCreateOrder(payload);
|
||||
if (res.ok) {
|
||||
setFormStatus(message.id, { status: 'done' });
|
||||
// ---- conversational order: submit what the chat gathered -------------
|
||||
if (message.form.kind === 'confirmOrder') {
|
||||
const draft = message.form.draft;
|
||||
const payload = buildOrderPayload(draft, draft.__pickup);
|
||||
const res = await executeCreateOrder(payload);
|
||||
if (res.ok) {
|
||||
setFormStatus(message.id, { status: 'done' });
|
||||
setFlow(null);
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
|
||||
detail: 'It appears on the Orders page under Pending until a rider is assigned.',
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
} else {
|
||||
setFormStatus(message.id, { status: 'failed', error: res.message });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message },
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- conversational customer: submit what the chat gathered ----------
|
||||
if (message.form.kind === 'confirmCustomer') {
|
||||
try {
|
||||
const res = await executeCreateCustomer(message.form.payload);
|
||||
if (res.ok) {
|
||||
setFormStatus(message.id, { status: 'done' });
|
||||
setFlow(null);
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `Created ${[message.form.payload.firstname, message.form.payload.lastname].filter(Boolean).join(' ')}.`,
|
||||
// Naming the store is worth keeping: two customer resources exist,
|
||||
// POST /admin/customers is a confirmed 405, and the Customers page
|
||||
// now reads this one — so the record does show up.
|
||||
detail: `Customer ID ${res.id ?? '—'}. Saved to /admin/tenantcustomers — it appears on the Customers page.`,
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
} else {
|
||||
setFormStatus(message.id, { status: 'failed', error: res.message });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message },
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Doormile AI] confirmCustomer failed', err);
|
||||
setFormStatus(message.id, { status: 'failed', error: err.message || 'Nothing was saved.' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- bulk orders ----
|
||||
if (message.form.kind === 'confirmBulk') {
|
||||
const { rows, invalid, ...shared } = message.form.draft;
|
||||
const res = await executeCreateBulk(rows, shared);
|
||||
// A bulk import is normally a PARTIAL result, so the outcome is reported
|
||||
// per row rather than as a single success/failure. Without that, the only
|
||||
// safe response to any error is to re-submit everything — which
|
||||
// double-books whatever did land.
|
||||
setFormStatus(message.id, {
|
||||
status: res.failed && !res.created ? 'failed' : 'done',
|
||||
error: res.failed ? `${res.failed} row(s) failed` : undefined
|
||||
});
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
|
||||
list: {
|
||||
title: 'Submitted',
|
||||
numbered: false,
|
||||
items: Object.entries(payload)
|
||||
.filter(([k]) => k !== 'parcels')
|
||||
.map(([k, v]) => ({ label: k, meta: String(v) }))
|
||||
.concat([{ label: 'parcels', meta: `${payload.parcels.length} · ${payload.parcels[0].itemdescription}` }])
|
||||
},
|
||||
detail: 'Saved to /admin/expressbooking. It will appear on the Orders page under Pending until a rider is assigned.',
|
||||
text: `Created ${res.created} of ${rows.length} order${rows.length === 1 ? '' : 's'}.`,
|
||||
list: res.failures.length
|
||||
? {
|
||||
title: 'Not created',
|
||||
numbered: false,
|
||||
items: res.failures.map((f) => ({ label: rows[f.index]?.customer_name || `Row ${f.index + 1}`, meta: f.reason }))
|
||||
}
|
||||
: undefined,
|
||||
detail:
|
||||
res.failed === 0
|
||||
? `All rows submitted${res.batches > 1 ? ` in ${res.batches} batches` : ''}. They appear on the Orders page under Pending.`
|
||||
: `${res.created} landed and ${res.failed} did not. Only the failed rows need re-submitting — re-sending everything would duplicate the ones that worked.`,
|
||||
// Rejected rows plus anything the check had already set aside, in the
|
||||
// template's own columns, so they can be fixed and re-uploaded
|
||||
// instead of re-sending a file that would double-book what landed.
|
||||
downloadRows: [...res.failures.map((f) => ({ ...rows[f.index], error: f.reason })), ...(invalid || [])],
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
} else {
|
||||
setFormStatus(message.id, { status: 'failed', error: res.message });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message },
|
||||
sourceCalls: res.sourceCalls
|
||||
setFlow(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- order ----
|
||||
if (message.form.kind === 'createOrder') {
|
||||
// The saved location the operator chose — its address/pincode/coords go
|
||||
// on the booking, because pickuplocationid 500s server-side.
|
||||
const payload = buildOrderPayload(values, values.__pickup);
|
||||
const res = await executeCreateOrder(payload);
|
||||
if (res.ok) {
|
||||
setFormStatus(message.id, { status: 'done' });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `Created order ${res.bookingno || res.id || ''}.`.trim(),
|
||||
list: {
|
||||
title: 'Submitted',
|
||||
numbered: false,
|
||||
items: Object.entries(payload)
|
||||
.filter(([k]) => k !== 'parcels')
|
||||
.map(([k, v]) => ({ label: k, meta: String(v) }))
|
||||
.concat([{ label: 'parcels', meta: `${payload.parcels.length} · ${payload.parcels[0].itemdescription}` }])
|
||||
},
|
||||
detail: 'Saved to /admin/expressbooking. It will appear on the Orders page under Pending until a rider is assigned.',
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
} else {
|
||||
setFormStatus(message.id, { status: 'failed', error: res.message });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message },
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = buildCustomerPayload(values);
|
||||
const res = await executeCreateCustomer(payload);
|
||||
if (res.ok) {
|
||||
setFormStatus(message.id, { status: 'done' });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `Created ${[payload.firstname, payload.lastname].filter(Boolean).join(' ')}.`,
|
||||
// Everything that was actually sent, so the operator can see the
|
||||
// whole record instead of a one-line summary.
|
||||
list: {
|
||||
title: 'Saved',
|
||||
numbered: false,
|
||||
items: Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }))
|
||||
},
|
||||
// Same store as the conversational path above.
|
||||
detail: `Customer ID ${res.id ?? '—'}. Saved to /admin/tenantcustomers — it appears on the Customers page.`,
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
} else {
|
||||
setFormStatus(message.id, { status: 'failed', error: res.message });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message },
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Doormile AI] createCustomer failed', err);
|
||||
setFormStatus(message.id, {
|
||||
status: 'failed',
|
||||
// executeCreateCustomer reports HTTP failures itself with the status and
|
||||
// endpoint; anything reaching here is unexpected.
|
||||
error: err.response?.data?.message || `${err.message || 'Unexpected error'} — nothing was saved.`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[push, setFormStatus]
|
||||
);
|
||||
|
||||
try {
|
||||
const payload = buildCustomerPayload(values);
|
||||
const res = await executeCreateCustomer(payload);
|
||||
if (res.ok) {
|
||||
setFormStatus(message.id, { status: 'done' });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
text: `Created ${[payload.firstname, payload.lastname].filter(Boolean).join(' ')}.`,
|
||||
// Everything that was actually sent, so the operator can see the
|
||||
// whole record instead of a one-line summary.
|
||||
list: {
|
||||
title: 'Saved',
|
||||
numbered: false,
|
||||
items: Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }))
|
||||
},
|
||||
detail: `Customer ID ${res.id ?? '—'}.\nSaved to /admin/customers — it should now appear on the Customers page.`,
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
} else {
|
||||
setFormStatus(message.id, { status: 'failed', error: res.message });
|
||||
push({
|
||||
sender: 'assistant',
|
||||
state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message },
|
||||
sourceCalls: res.sourceCalls
|
||||
});
|
||||
// A dropdown selection. Same code path as a typed answer — the only
|
||||
// difference is that the chosen OPTION comes along too, because steps that
|
||||
// pick a record (a location, a customer) fill several fields from it.
|
||||
const chooseStep = useCallback(
|
||||
async (message, value, option) => {
|
||||
if (!flow || flow.complete) return;
|
||||
// Mark the rendered dropdown as spent so it can't be submitted twice while
|
||||
// the next step is loading.
|
||||
setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m)));
|
||||
setIsSending(true);
|
||||
try {
|
||||
const isBulk = flow.kind === 'createBulk';
|
||||
const next = isBulk ? await answerBulkStep(flow, value, option) : await answerOrderStep(flow, value, option);
|
||||
setFlow(next.flow);
|
||||
await renderFlowStep(next, isBulk ? finishBulk : finishOrder);
|
||||
} catch (err) {
|
||||
console.error('[Doormile AI] flow step failed', err);
|
||||
push({ sender: 'assistant', text: 'Something went wrong on that step — say “cancel” and start again.' });
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Doormile AI] createCustomer failed', err);
|
||||
setFormStatus(message.id, {
|
||||
status: 'failed',
|
||||
// executeCreateCustomer reports HTTP failures itself with the status and
|
||||
// endpoint; anything reaching here is unexpected.
|
||||
error: err.response?.data?.message || `${err.message || 'Unexpected error'} — nothing was saved.`
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
[flow, push, renderFlowStep, finishBulk, finishOrder]
|
||||
);
|
||||
|
||||
const cancelAction = (message) => {
|
||||
if (message.form?.status !== 'open') return;
|
||||
setFormStatus(message.id, { status: 'cancelled' });
|
||||
};
|
||||
// Stops the long locate/price pass. The flag is a ref for the reason
|
||||
// documented on it — as state it was captured at call time and never seen.
|
||||
const stopLive = useCallback(
|
||||
(message) => {
|
||||
bulkStop.current = true;
|
||||
patch(message.id, { text: 'Stopping — the rows already checked are kept.', liveStop: false });
|
||||
},
|
||||
[patch]
|
||||
);
|
||||
|
||||
const cancelAction = useCallback(
|
||||
(message) => {
|
||||
if (message.form?.status !== 'open') return;
|
||||
setFormStatus(message.id, { status: 'cancelled' });
|
||||
},
|
||||
[setFormStatus]
|
||||
);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
@@ -361,6 +814,8 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
onAsk={ask}
|
||||
onSubmitForm={submitForm}
|
||||
onCancelAction={cancelAction}
|
||||
onChooseStep={chooseStep}
|
||||
onStopLive={stopLive}
|
||||
/>
|
||||
))}
|
||||
{isSending && (
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { LuSparkles } from 'react-icons/lu';
|
||||
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
|
||||
// ==============================|| Doormile AI — shared primitives ||============================== //
|
||||
//
|
||||
// Small presentational pieces shared by the panel's states. Kept in one file
|
||||
// because each is a handful of lines and they are only ever used together.
|
||||
|
||||
// The AI mark — a soft gradient orb, deliberately not a robot face.
|
||||
// The Doormile D. One component behind the assistant's identity, so the
|
||||
// header, every reply, the welcome screen and the thinking state can't drift
|
||||
// apart — they all render this. The mark is RGBA with a transparent ground, so
|
||||
// it sits on the panel surface rather than needing a coloured chip behind it.
|
||||
export const Spark = ({ size = 'sm' }) => (
|
||||
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||
<LuSparkles size={size === 'lg' ? 20 : size === 'md' ? 15 : 12} strokeWidth={2.1} />
|
||||
<img src={doormileMark} alt="" />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
|
||||
150
src/pages/nearle/assistant/DoormileAI/AIRowsStep.js
Normal file
150
src/pages/nearle/assistant/DoormileAI/AIRowsStep.js
Normal file
@@ -0,0 +1,150 @@
|
||||
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 '@astryxdesign/core/Button';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { FileInput } from '@astryxdesign/core/FileInput';
|
||||
|
||||
import { parseBulkRows, BULK_MAX } from '../bulkOrderActions';
|
||||
import { parseBulkFile, templateCsv, downloadCsv } from '../bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — the rows turn ||============================== //
|
||||
//
|
||||
// One conversational turn that accepts a whole file. A sheet and a paste both
|
||||
// produce the same row array (bulkFile.js / parseBulkRows), so this hands back
|
||||
// one parse result either way and the flow doesn't care which was used.
|
||||
//
|
||||
// File and paste are offered together rather than behind a "file or paste?"
|
||||
// question, because that question costs a turn and answers nothing the operator
|
||||
// hasn't already decided by having a file or not.
|
||||
//
|
||||
// What is reported before the operator can continue, and why each matters:
|
||||
// • rows read — so a truncated or half-empty sheet is visible immediately
|
||||
// • columns NOT recognised, by name — an unread price column would otherwise
|
||||
// silently re-quote every row
|
||||
// • rows repeated inside the file — the bulk endpoint has no idempotency key,
|
||||
// so a duplicate that gets through is a second real rider at one door
|
||||
|
||||
const PLACEHOLDER = `Ramesh Kumar, 9876543210, 12 Cross Cut Rd Gandhipuram, 641012, Coimbatore, 120, 2 meal boxes
|
||||
Priya N, 9876500011, 45 RS Puram, 641002, Coimbatore, 95, documents`;
|
||||
|
||||
const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [fileParse, setFileParse] = useState(null);
|
||||
const [fileError, setFileError] = useState(null);
|
||||
const [raw, setRaw] = useState('');
|
||||
const [isReading, setIsReading] = useState(false);
|
||||
|
||||
const pasted = raw.trim() ? parseBulkRows(raw) : null;
|
||||
// A loaded file wins; clearing it falls back to whatever is in the textarea.
|
||||
const parsed = fileParse || pasted;
|
||||
|
||||
const handleFile = async (picked) => {
|
||||
const chosen = Array.isArray(picked) ? picked[0] : picked;
|
||||
setFile(chosen || null);
|
||||
setFileError(null);
|
||||
if (!chosen) {
|
||||
setFileParse(null);
|
||||
return;
|
||||
}
|
||||
setIsReading(true);
|
||||
try {
|
||||
const result = await parseBulkFile(chosen);
|
||||
setFileParse(result);
|
||||
setRaw('');
|
||||
} catch (err) {
|
||||
setFileParse(null);
|
||||
setFileError(err.message || 'Couldn’t read that file.');
|
||||
} finally {
|
||||
setIsReading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<FileInput
|
||||
label="Order sheet"
|
||||
isLabelHidden
|
||||
accept=".csv,.xls,.xlsx"
|
||||
value={file}
|
||||
onChange={handleFile}
|
||||
isDisabled={isBusy || isReading}
|
||||
/>
|
||||
|
||||
{!fileParse && (
|
||||
<TextArea label="Or paste rows" rows={4} value={raw} onChange={setRaw} placeholder={PLACEHOLDER} isDisabled={isBusy} />
|
||||
)}
|
||||
|
||||
{isReading && <Text className="dai-action-note">Reading the file…</Text>}
|
||||
{fileError && <Text className="dai-field-err">{fileError}</Text>}
|
||||
|
||||
{parsed && (
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-action-note">
|
||||
{fileParse ? `${fileParse.sheetName} — ` : ''}
|
||||
{parsed.rows.length} complete row{parsed.rows.length === 1 ? '' : 's'}
|
||||
{parsed.errors.length ? ` · ${parsed.errors.length} unusable` : ''}
|
||||
</Text>
|
||||
|
||||
{parsed.ignoredColumns?.length > 0 && (
|
||||
<Text className="dai-action-note">Columns I didn’t recognise, so didn’t use: {parsed.ignoredColumns.join(', ')}</Text>
|
||||
)}
|
||||
|
||||
{parsed.duplicates?.length > 0 && (
|
||||
<Text className="dai-field-err">
|
||||
{parsed.duplicates.length} row{parsed.duplicates.length === 1 ? '' : 's'} repeat an earlier phone and address (
|
||||
{parsed.duplicates.map((d) => `line ${d.line}`).join(', ')}) — they’d become separate orders.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{parsed.errors.slice(0, 5).map((e) => (
|
||||
<Text key={e.line} className="dai-field-err">
|
||||
Line {e.line}: {e.reason}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <Text className="dai-action-note">…and {parsed.errors.length - 5} more unusable rows.</Text>}
|
||||
|
||||
{parsed.rows.length > BULK_MAX && (
|
||||
<Text className="dai-action-note">
|
||||
Over {BULK_MAX} — I’ll send them in {Math.ceil(parsed.rows.length / BULK_MAX)} batches.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={parsed?.rows.length ? `Use these ${parsed.rows.length} rows` : 'Continue'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!parsed?.rows.length || isBusy || isReading}
|
||||
onClick={() => onSubmit(parsed)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
<Button
|
||||
label="Template"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tooltip="Download a blank sheet with the exact column titles I read"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-template.csv', templateCsv())}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text className="dai-action-note">
|
||||
Pasted rows: name, phone, address, pincode, city, price, description — comma, tab or pipe separated. Leave the price blank and I’ll
|
||||
quote it.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIRowsStep.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIRowsStep;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
import { LuArrowRight } from 'react-icons/lu';
|
||||
@@ -41,9 +40,10 @@ SuggestionCard.propTypes = {
|
||||
};
|
||||
|
||||
const AIWelcome = ({ context, onAsk }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const more = context.more || [];
|
||||
const shown = expanded ? [...context.suggestions, ...more] : context.suggestions;
|
||||
// Every suggestion for this page, always. `context.more` used to sit behind a
|
||||
// "View N more" / "Show fewer" toggle; hiding half the things the assistant
|
||||
// can answer behind a click made it look narrower than it is.
|
||||
const shown = [...context.suggestions, ...(context.more || [])];
|
||||
|
||||
return (
|
||||
<VStack className="dai-welcome" gap={3} padding={0}>
|
||||
@@ -65,11 +65,6 @@ const AIWelcome = ({ context, onAsk }) => {
|
||||
<SuggestionCard key={item.text} item={item} onAsk={onAsk} />
|
||||
))}
|
||||
</VStack>
|
||||
{more.length > 0 && (
|
||||
<button type="button" className="dai-link" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded}>
|
||||
{expanded ? 'Show fewer' : `View ${more.length} more`}
|
||||
</button>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { LuSparkles } from 'react-icons/lu';
|
||||
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
@@ -33,7 +33,7 @@ const DoormileAITrigger = () => {
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<LuSparkles size={16} strokeWidth={2.1} aria-hidden="true" />
|
||||
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
LuLayers,
|
||||
LuTimerOff,
|
||||
LuUserPlus,
|
||||
LuPackagePlus
|
||||
LuPackagePlus,
|
||||
LuListPlus
|
||||
} from 'react-icons/lu';
|
||||
|
||||
// ==============================|| Doormile AI — page context ||============================== //
|
||||
@@ -34,6 +35,7 @@ const ORDERS = {
|
||||
{ icon: LuPackagePlus, text: 'Create an order' }
|
||||
],
|
||||
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?' },
|
||||
@@ -159,7 +161,43 @@ const ROUTES = [
|
||||
['/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)];
|
||||
|
||||
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;
|
||||
})();
|
||||
|
||||
const withEveryQuestion = (context) => {
|
||||
const seen = new Set();
|
||||
const suggestions = [];
|
||||
[...(context.suggestions || []), ...(context.more || []), ...EVERY_QUESTION].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 match ? match[1] : DEFAULT_CONTEXT;
|
||||
return withEveryQuestion(match ? match[1] : DEFAULT_CONTEXT);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAdminCustomer } from 'pages/api/doormileApi';
|
||||
import { createTenantCustomer } from 'pages/api/doormileApi';
|
||||
|
||||
// ==============================|| Doormile AI — write actions ||============================== //
|
||||
//
|
||||
@@ -102,35 +102,43 @@ export const buildCustomerPayload = (draft) =>
|
||||
//
|
||||
// ---- Which endpoint, and why -----------------------------------------------
|
||||
//
|
||||
// Writes to POST /admin/customers — the same resource the Customers page
|
||||
// reads, so a created customer shows up there immediately.
|
||||
// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
|
||||
// by reading the docs:
|
||||
//
|
||||
// Note for anyone reading express-console-api.md: that document lists
|
||||
// /admin/customers as GET + PATCH only, and describes a customer there as
|
||||
// something created by a booking (POST /admin/expressbooking documents
|
||||
// `customer_phone` as "creates a Guest customer if unknown"). This call was
|
||||
// pointed here deliberately anyway. If the route turns out not to exist the
|
||||
// server answers 404/405 and the assistant shows that verbatim alongside the
|
||||
// endpoint name, so the failure is loud rather than silent.
|
||||
// POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
//
|
||||
// The previous target was POST /admin/tenantcustomers, which succeeded but
|
||||
// wrote to a different store with its own id sequence — a customer created
|
||||
// that way came back as id 2 while the Customers list ran to id 44, and was
|
||||
// invisible on that page. That is the bug this change fixes.
|
||||
// 405 is the unambiguous answer: the route exists, and POST is not among its
|
||||
// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
|
||||
// and the server agrees. It was pointed there briefly on explicit instruction;
|
||||
// the live 405 settled it.
|
||||
//
|
||||
// The payload already mirrors this resource's own GET shape (firstname,
|
||||
// lastname, phone, email, address, doorno, landmark, suburb, city, state,
|
||||
// postcode, latitude, longitude), so nothing else had to move.
|
||||
// The consequence, which the assistant states in its success message rather
|
||||
// than leaving the operator to discover: a customer created here does NOT
|
||||
// appear on the Customers page, because that page reads GET /admin/customers.
|
||||
// On that resource a customer comes into existence as a side effect of a
|
||||
// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
|
||||
// Guest customer if unknown". A B2C customer is, by design, someone who has
|
||||
// ordered.
|
||||
//
|
||||
// To make created customers visible on that page, one of these has to happen:
|
||||
// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
|
||||
// it changes what that page means, and its edit dialog would then PATCH a
|
||||
// different store by id), or
|
||||
// • the backend adds POST /admin/customers.
|
||||
//
|
||||
// The payload keeps the address fields. The documented body is
|
||||
// { firstname, lastname, phone, email }; the rest are sent best-effort and
|
||||
// ignored if unsupported.
|
||||
export const executeCreateCustomer = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createAdminCustomer',
|
||||
target: 'POST /admin/customers',
|
||||
name: 'createTenantCustomer',
|
||||
target: 'POST /admin/tenantcustomers',
|
||||
stats: Object.keys(payload).join(', ')
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await createAdminCustomer(payload);
|
||||
const res = await createTenantCustomer(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
// doormileApi mutations return the full envelope, so a `success: false`
|
||||
@@ -158,19 +166,22 @@ export const executeCreateCustomer = async (payload) => {
|
||||
// from "the body was wrong". Report the status, the server's own message,
|
||||
// and name the endpoint.
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
const status = err.response?.status;
|
||||
const serverMessage = err.response?.data?.message || err.response?.data?.error;
|
||||
// doormileAxios rejects with the response BODY, not the axios error, so
|
||||
// `err.response` is undefined here — the status arrives as `err.httpStatus`.
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
|
||||
let message;
|
||||
if (status === 404 || status === 405) {
|
||||
// express-console-api.md documents /admin/customers as GET + PATCH only.
|
||||
// If the server agrees, say so plainly rather than implying the operator
|
||||
// typed something wrong.
|
||||
message =
|
||||
`POST /admin/customers returned ${status} — that route does not exist on the server. ` +
|
||||
'A customer there is created by a booking, or via POST /admin/tenantcustomers.';
|
||||
if (status === 405) {
|
||||
// 405 is "the route exists but not this method" — a different fact from
|
||||
// 404, and worth stating precisely so nobody re-tries the same call.
|
||||
message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
|
||||
} else if (status === 404) {
|
||||
message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
|
||||
} else if (status === 400 || status === 422) {
|
||||
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else if (status) {
|
||||
message = `POST /admin/customers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else {
|
||||
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
|
||||
}
|
||||
|
||||
223
src/pages/nearle/assistant/bulkFile.js
Normal file
223
src/pages/nearle/assistant/bulkFile.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order file upload ||============================== //
|
||||
//
|
||||
// Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from
|
||||
// a paste, so everything downstream — geocoding, validation, review, the chunked
|
||||
// submit, the per-row outcome report — is untouched by where the rows came from.
|
||||
//
|
||||
// Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the
|
||||
// ones multipleOrders.js uses, as is the column map. A sheet that uploads on
|
||||
// that page uploads here.
|
||||
//
|
||||
// Three reporting rules, all of them about not lying by omission:
|
||||
//
|
||||
// • An unparseable row becomes a REPORTED error with its line number, never a
|
||||
// silently skipped line. A bulk import that quietly drops row 14 is worse
|
||||
// than one that refuses outright.
|
||||
// • Columns that were not recognised are NAMED. An operator whose price
|
||||
// column is titled something unexpected has to be told it was ignored, or
|
||||
// they'll submit 200 orders priced from a column nothing ever read.
|
||||
// • Rows duplicated inside the file are flagged BEFORE submit. The bulk
|
||||
// endpoint has no idempotency key, so a duplicate that gets through is a
|
||||
// second real rider dispatched to the same door.
|
||||
|
||||
const CSV_EXT = /\.csv$/i;
|
||||
const EXCEL_EXT = /\.xlsx?$/i;
|
||||
|
||||
const digits = (v) => String(v ?? '').replace(/\D/g, '');
|
||||
const text = (v) => String(v ?? '').trim();
|
||||
|
||||
// A sheet cell can be a number, a date, or padded text — normalise to the same
|
||||
// shapes parseBulkRows yields so validateBulkRow behaves identically.
|
||||
const shapeRow = (raw, line) => {
|
||||
const { row, ignored } = mapSheetRow(raw);
|
||||
|
||||
const lat = Number(row.deliverylatitude);
|
||||
const lng = Number(row.deliverylongitude);
|
||||
const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0;
|
||||
|
||||
return {
|
||||
ignored,
|
||||
row: {
|
||||
line,
|
||||
customer_name: text(row.customer_name),
|
||||
// A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91
|
||||
// 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits
|
||||
// and drop a leading country/trunk prefix the same way the single-order
|
||||
// flow does.
|
||||
customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''),
|
||||
deliveryaddress: text(row.deliveryaddress),
|
||||
deliverypincode: digits(row.deliverypincode),
|
||||
deliverycity: text(row.deliverycity),
|
||||
// Blank is meaningful: it means "quote this row from the tenant's pricing
|
||||
// row and the routed distance", the same as the single-order flow. It is
|
||||
// NOT zero.
|
||||
finalprice: text(row.finalprice),
|
||||
itemdescription: text(row.itemdescription) || 'Order',
|
||||
itemcategory: text(row.itemcategory) || 'General',
|
||||
quantity: Math.max(1, Number(row.quantity) || 1),
|
||||
weight: text(row.weight),
|
||||
// Coordinates from the sheet let the geocode pass skip this row entirely,
|
||||
// which on a 200-row file is the difference between minutes and seconds.
|
||||
...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {})
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Structural check only — enough to know the row is worth geocoding. The full
|
||||
// gate is validateBulkRow, applied after coordinates exist.
|
||||
const structuralError = (row) => {
|
||||
if (!row.customer_name) return 'No receiver name';
|
||||
if (!row.deliveryaddress) return 'No delivery address';
|
||||
if (!row.customer_phone) return 'No phone number';
|
||||
if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapSheetRecords = (records, headers, sheetName) => {
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
const ignoredColumns = new Set();
|
||||
|
||||
records.forEach((raw, i) => {
|
||||
// +2: the header row is line 1, so the first data row is line 2 — the line
|
||||
// number an operator sees in their own spreadsheet.
|
||||
const line = i + 2;
|
||||
const { row, ignored } = shapeRow(raw, line);
|
||||
ignored.forEach((c) => ignoredColumns.add(c));
|
||||
|
||||
// A trailing blank row is an artefact of the file, not an operator error.
|
||||
if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return;
|
||||
|
||||
const error = structuralError(row);
|
||||
if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error });
|
||||
else rows.push(row);
|
||||
});
|
||||
|
||||
const normalised = headers.map(normalizeHeader);
|
||||
const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c)));
|
||||
|
||||
return {
|
||||
rows,
|
||||
errors,
|
||||
sheetName,
|
||||
ignoredColumns: [...ignoredColumns],
|
||||
// Reported, not enforced: the page only warns about these too, and a
|
||||
// hand-built sheet using plain headers ("name", "phone") legitimately has
|
||||
// none of the tenant's official titles while still being complete.
|
||||
missingRequired,
|
||||
recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()),
|
||||
duplicates: findDuplicateRows(rows)
|
||||
};
|
||||
};
|
||||
|
||||
// Same recipient at the same address twice in one file. Reported, never removed
|
||||
// automatically — two parcels to one door is a legitimate order, and deciding
|
||||
// which is which is the operator's call, not the parser's.
|
||||
export const findDuplicateRows = (rows) => {
|
||||
const seen = new Map();
|
||||
const dupes = [];
|
||||
rows.forEach((r) => {
|
||||
const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`;
|
||||
if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name });
|
||||
else seen.set(key, r.line);
|
||||
});
|
||||
return dupes;
|
||||
};
|
||||
|
||||
export const parseBulkFile = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file selected.'));
|
||||
return;
|
||||
}
|
||||
const isCsv = CSV_EXT.test(file.name);
|
||||
const isExcel = EXCEL_EXT.test(file.name);
|
||||
if (!isCsv && !isExcel) {
|
||||
reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCsv) {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
dynamicTyping: false,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (!results.data?.length) {
|
||||
reject(new Error('That CSV has a header row but no data rows.'));
|
||||
return;
|
||||
}
|
||||
resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name));
|
||||
},
|
||||
error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`))
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Couldn’t read that file.'));
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const workbook = XLSX.read(e.target.result, { type: 'binary' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
// Only the first sheet is read, and the name is reported back so an
|
||||
// operator whose data sits on "Sheet2" can see which one was used.
|
||||
const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false });
|
||||
if (!records?.length) {
|
||||
reject(new Error(`Sheet “${sheetName}” is empty.`));
|
||||
return;
|
||||
}
|
||||
resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`));
|
||||
} catch (err) {
|
||||
reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`));
|
||||
}
|
||||
};
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
|
||||
// ---- downloads --------------------------------------------------------------
|
||||
|
||||
// Hands the operator a file built from data they already supplied — a Blob
|
||||
// assembled in the page, not a fetch and not an upload.
|
||||
export const downloadCsv = (filename, csv) => {
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toCsv = (headers, rows) =>
|
||||
[headers, ...rows]
|
||||
.map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(','))
|
||||
.join('\r\n');
|
||||
|
||||
// A blank sheet with the exact headers this parser reads, so operators stop
|
||||
// guessing at column titles.
|
||||
export const templateCsv = () =>
|
||||
toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]);
|
||||
|
||||
// The rows that did NOT go through, in the same column shape, so they can be
|
||||
// fixed and re-uploaded. This is what makes a partial success recoverable
|
||||
// without re-submitting the rows that already landed.
|
||||
export const failedRowsCsv = (failed) =>
|
||||
toCsv(
|
||||
[...TEMPLATE_HEADERS, 'Reason'],
|
||||
failed.map((r) => [
|
||||
r.customer_name || '',
|
||||
r.customer_phone || '',
|
||||
r.deliveryaddress || '',
|
||||
r.deliverypincode || '',
|
||||
r.deliverycity || '',
|
||||
r.itemdescription || '',
|
||||
r.quantity ?? 1,
|
||||
r.finalprice ?? '',
|
||||
r.error || r.reason || 'Rejected'
|
||||
])
|
||||
);
|
||||
193
src/pages/nearle/assistant/bulkFlow.js
Normal file
193
src/pages/nearle/assistant/bulkFlow.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational bulk create ||============================== //
|
||||
//
|
||||
// Same conversation shape as orderFlow.js — one question per turn, a dropdown
|
||||
// wherever the page uses one — but the rows come from a sheet instead of being
|
||||
// dictated one field at a time. The form version was replaced on explicit
|
||||
// direction: "don't show it as the form way, it should be like chatting".
|
||||
//
|
||||
// The steps deliberately mirror the single-order flow's opening, because they
|
||||
// ARE the same questions: which tenant, which pickup location, which service.
|
||||
// Only the last step differs — a whole file instead of one recipient.
|
||||
//
|
||||
// What is NOT a step: locating and pricing the rows. Those are a long-running
|
||||
// pass over the whole file (~1 lookup/second), so the panel runs them after the
|
||||
// last answer and reports progress into the conversation. Making them a "step"
|
||||
// would mean a question nobody is being asked.
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
export const BULK_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant are these orders for?',
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
// One pickup location for the whole file — the same shape the bulk page
|
||||
// uses, and what makes a single batch dispatchable.
|
||||
ask: 'Which business location are they all picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
// Refused here rather than after submitting: CityGate runs server-side
|
||||
// before the handler, and would reject every row in the file with an
|
||||
// opaque middleware error.
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level for all of them?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'rows',
|
||||
type: 'rows',
|
||||
ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
|
||||
// The whole parse result is stored, not just the rows: the ignored columns
|
||||
// and in-file duplicates have to be reportable, and a count of rows alone
|
||||
// can't say what was quietly not read.
|
||||
validate: (parsed) =>
|
||||
parsed?.rows?.length
|
||||
? null
|
||||
: 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
|
||||
apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
|
||||
}
|
||||
];
|
||||
|
||||
export const startBulkFlow = () => {
|
||||
// Same seeding rule as the single-order flow: a client login skips the tenant
|
||||
// question, so the id has to be in the draft or the payload sends NaN.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
|
||||
export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
|
||||
|
||||
// ---- the long pass: locate, then price --------------------------------------
|
||||
//
|
||||
// Extracted from the old form so the conversation can run it and narrate it.
|
||||
// Two economies keep a large file practical, and both are load-bearing:
|
||||
//
|
||||
// • a sheet carrying latitude/longitude columns skips the lookup entirely
|
||||
// • results are cached by address, so a re-run after fixing a few rows does
|
||||
// not re-look-up the ones that were already fine
|
||||
//
|
||||
// `shouldStop` is read through a function, never a captured boolean — as state
|
||||
// it was evaluated once at call time and Stop did nothing for 200 rows.
|
||||
export const GEOCODE_INTERVAL_MS = 1100;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
|
||||
export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
|
||||
|
||||
export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
|
||||
const located = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = rows[i];
|
||||
|
||||
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?.(),
|
||||
deliverylongitude: place?.geometry?.location?.lng?.(),
|
||||
resolvedAddress: place?.formatted_address
|
||||
};
|
||||
cache?.set(key, found);
|
||||
located.push({ ...row, ...found });
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Rows never reached because Stop was pressed keep no coordinates, so they
|
||||
// report as unsendable instead of vanishing from the count.
|
||||
if (located.length < rows.length) located.push(...rows.slice(located.length));
|
||||
|
||||
// Only a row that is located AND unpriced needs a routing call. Pricing an
|
||||
// unlocatable row spends an OSRM request just to fail, and re-pricing a row
|
||||
// that carried its own price would overwrite the operator's number.
|
||||
const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
|
||||
|
||||
// Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
|
||||
// ADDRESS lookups; pricing is unthrottled and bounded by what was already
|
||||
// located. Gating it here meant a Stop mid-lookup left every located row
|
||||
// unpriced and therefore unsendable — throwing away exactly the work the
|
||||
// operator is told is kept.
|
||||
const priced = needPricing.length
|
||||
? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
|
||||
: [];
|
||||
const pricedByLine = new Map(priced.map((r) => [r.line, r]));
|
||||
|
||||
const checked = located.map((r) => {
|
||||
const merged = pricedByLine.get(r.line) || r;
|
||||
return {
|
||||
...merged,
|
||||
// The pricing reason is more specific than "Price must be a number", so it
|
||||
// wins when both apply.
|
||||
error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: checked,
|
||||
valid: checked.filter((r) => !r.error),
|
||||
invalid: checked.filter((r) => r.error)
|
||||
};
|
||||
};
|
||||
|
||||
// How many rows still need a network lookup — the only honest basis for an ETA.
|
||||
export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
|
||||
|
||||
export const batchCount = (n) => Math.ceil(n / BULK_MAX);
|
||||
261
src/pages/nearle/assistant/bulkOrderActions.js
Normal file
261
src/pages/nearle/assistant/bulkOrderActions.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import { createExpressBookingBulk, getAdminPricing } from 'pages/api/doormileApi';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from 'utils/distance';
|
||||
import { buildOrderPayload } from './orderActions';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order creation ||============================== //
|
||||
//
|
||||
// Third write capability, and the highest-blast-radius one: a single press can
|
||||
// dispatch dozens of riders. Everything here is built around making that
|
||||
// visible BEFORE it happens and legible AFTER.
|
||||
//
|
||||
// Shared with the single-order path on purpose:
|
||||
// • buildOrderPayload — so a bulk row and a single order are byte-identical
|
||||
// on the wire, including the pickuplocationid workaround (that field 500s
|
||||
// server-side; raw pickup fields are sent instead).
|
||||
// • validateOrderDraft — the same gate, applied per row.
|
||||
|
||||
// Server cap, documented in express-console-api.md. Exceeding it is a hard
|
||||
// error rather than a silent truncation, so rows are chunked instead.
|
||||
export const BULK_MAX = 200;
|
||||
|
||||
export const CREATE_BULK_TRIGGER =
|
||||
/\b(?:create|add|place|book|new|bulk|multiple)\s+(?:multiple|many|several|bulk|\d+)\s*(?:orders|bookings|deliveries)\b|\bbulk\s+(?:order|booking|upload)\b|\bmultiple\s+orders\b/i;
|
||||
|
||||
// ---- Parsing a pasted list --------------------------------------------------
|
||||
//
|
||||
// Operators paste from a spreadsheet, so accept the shapes that actually
|
||||
// arrive: comma, tab or pipe separated, one row per line, with an optional
|
||||
// header row.
|
||||
//
|
||||
// name, phone, address, pincode, city, price, description
|
||||
//
|
||||
// Anything unparseable becomes a REPORTED row error rather than a silently
|
||||
// dropped line — a bulk import that quietly skips row 14 is worse than one
|
||||
// that refuses.
|
||||
const SPLIT = /\t|\||,(?![^(]*\))/;
|
||||
|
||||
const HEADER_HINT = /name|phone|mobile|address|pincode|city|price|amount|item|description/i;
|
||||
|
||||
export const parseBulkRows = (text) => {
|
||||
const lines = String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!lines.length) return { rows: [], errors: [] };
|
||||
|
||||
// Drop a header row only when it looks like one AND carries no phone number.
|
||||
const first = lines[0];
|
||||
const looksLikeHeader = HEADER_HINT.test(first) && !/\d{10}/.test(first);
|
||||
const body = looksLikeHeader ? lines.slice(1) : lines;
|
||||
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
|
||||
body.forEach((line, i) => {
|
||||
const parts = line.split(SPLIT).map((p) => p.trim());
|
||||
const lineNo = (looksLikeHeader ? 2 : 1) + i;
|
||||
if (parts.length < 4) {
|
||||
errors.push({ line: lineNo, text: line, reason: 'Needs at least name, phone, address and pincode' });
|
||||
return;
|
||||
}
|
||||
const [customer_name, customer_phone, deliveryaddress, deliverypincode, deliverycity, finalprice, itemdescription] = parts;
|
||||
rows.push({
|
||||
line: lineNo,
|
||||
customer_name,
|
||||
customer_phone: String(customer_phone || '').replace(/\D/g, ''),
|
||||
deliveryaddress,
|
||||
deliverypincode: String(deliverypincode || '').replace(/\D/g, ''),
|
||||
deliverycity: deliverycity || '',
|
||||
finalprice: finalprice || '',
|
||||
itemdescription: itemdescription || 'Order',
|
||||
itemcategory: 'General',
|
||||
quantity: 1
|
||||
});
|
||||
});
|
||||
|
||||
return { rows, errors };
|
||||
};
|
||||
|
||||
// Per-row validation. Deliberately NOT validateOrderDraft: a pasted row has no
|
||||
// coordinates (there is no address search on a paste), and the single-order
|
||||
// gate requires them. Bulk rows are geocoded by the caller before submit, and
|
||||
// rows that fail to geocode are reported, not sent.
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
|
||||
export const validateBulkRow = (row) => {
|
||||
if (!row.customer_name) return 'Missing customer name';
|
||||
if (!PHONE_RE.test(row.customer_phone)) return 'Phone must be exactly 10 digits';
|
||||
if (!row.deliveryaddress) return 'Missing delivery address';
|
||||
if (!row.deliverypincode) return 'Missing delivery pincode';
|
||||
// Coordinates are checked BEFORE the price, because an unlocatable address is
|
||||
// the root cause and a blank price is its symptom — the row was never priced
|
||||
// precisely because there was nothing to route. Reporting "price must be a
|
||||
// number" here sent the operator to fix the wrong column.
|
||||
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
||||
return 'Address could not be located — the order could not be routed';
|
||||
}
|
||||
if (row.finalprice === '' || Number.isNaN(Number(row.finalprice))) return 'Price must be a number';
|
||||
return null;
|
||||
};
|
||||
|
||||
// ---- per-row pricing --------------------------------------------------------
|
||||
//
|
||||
// A blank price column means "quote it", exactly as the single-order flow does —
|
||||
// not zero. The tenant's pricing row is fetched ONCE for the whole file (fetching
|
||||
// it per row would be 200 identical requests), then each unpriced row costs one
|
||||
// OSRM call for its routed distance.
|
||||
//
|
||||
// A row that can't be priced keeps its blank price and carries the reason. It
|
||||
// then fails validateBulkRow and is reported, rather than being submitted at a
|
||||
// number nobody chose.
|
||||
export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, shouldStop } = {}) => {
|
||||
const pricing = (await getAdminPricing()) || [];
|
||||
// tenantid is numeric on the pricing row and a string from localStorage — a
|
||||
// strict comparison here silently priced every order at zero once before.
|
||||
const match = pricing.find((p) => String(p.tenantid) === String(tenantid));
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const row = rows[i];
|
||||
// A stop leaves the remaining rows exactly as they were — unpriced and
|
||||
// therefore invalid — instead of half-pricing the file.
|
||||
if (shouldStop?.()) {
|
||||
out.push(...rows.slice(i));
|
||||
break;
|
||||
}
|
||||
onProgress?.({ done: i, total: rows.length, current: row.customer_name || row.deliveryaddress });
|
||||
|
||||
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 }
|
||||
).catch(() => null);
|
||||
|
||||
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);
|
||||
out.push({ ...row, finalprice: Number(Number(total).toFixed(2)), km, quoted: true });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---- double-submit protection ----------------------------------------------
|
||||
//
|
||||
// POST /admin/expressbooking/bulk takes no idempotency key, so if a submit times
|
||||
// out the operator cannot tell what landed — and re-sending the file double-books
|
||||
// every row that succeeded. Fingerprints of what has already been submitted this
|
||||
// session are kept so an identical re-submit can at least be questioned.
|
||||
//
|
||||
// Session-scoped on purpose: it guards the realistic accident (pressing Create
|
||||
// twice, or re-uploading the same file minutes later), not a next-day re-run,
|
||||
// which may be a legitimately repeated delivery round.
|
||||
const submitted = new Set();
|
||||
|
||||
export const rowSetFingerprint = (rows) =>
|
||||
(rows || [])
|
||||
.map((r) => `${r.customer_phone}|${r.deliverypincode}|${r.finalprice}`)
|
||||
.sort()
|
||||
.join(';');
|
||||
|
||||
export const wasAlreadySubmitted = (rows) => rows?.length > 0 && submitted.has(rowSetFingerprint(rows));
|
||||
|
||||
const chunk = (arr, size) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
};
|
||||
|
||||
// The only bulk-writing function in the assistant.
|
||||
//
|
||||
// Returns a per-row outcome, never a bare success/failure. A partial success
|
||||
// is the normal case for a bulk import, and the operator has to be able to see
|
||||
// exactly which rows landed — otherwise the only safe response to any error is
|
||||
// to assume nothing worked and re-submit everything, which double-books.
|
||||
export const executeCreateBulk = async (rows, shared) => {
|
||||
const started = Date.now();
|
||||
// Recorded BEFORE the request, not after: a timed-out submit is the case that
|
||||
// most needs the warning, and it never reaches a success handler.
|
||||
submitted.add(rowSetFingerprint(rows));
|
||||
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, shared.__pickup));
|
||||
const batches = chunk(payloads, BULK_MAX);
|
||||
|
||||
const sourceCalls = [];
|
||||
let created = 0;
|
||||
const failures = [];
|
||||
|
||||
for (let b = 0; b < batches.length; b += 1) {
|
||||
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);
|
||||
const perRow = Array.isArray(res?.data) ? res.data : Array.isArray(res?.results) ? res.results : null;
|
||||
|
||||
if (res?.success === false) {
|
||||
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
|
||||
sourceCalls.push({
|
||||
name: 'createExpressBookingBulk',
|
||||
target: `POST /admin/expressbooking/bulk${label}`,
|
||||
status: 'error',
|
||||
errorMessage: res.message || 'Rejected'
|
||||
});
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// The endpoint is documented as returning per-row results. If it does,
|
||||
// trust it row by row; if it doesn't, treat the batch as all-or-nothing
|
||||
// rather than inventing a success count.
|
||||
if (perRow) {
|
||||
perRow.forEach((r, i) => {
|
||||
if (r?.success === false || r?.error) failures.push({ index: b * BULK_MAX + i, reason: r.message || r.error || 'Rejected' });
|
||||
else created += 1;
|
||||
});
|
||||
} else {
|
||||
created += batch.length;
|
||||
}
|
||||
|
||||
sourceCalls.push({
|
||||
name: 'createExpressBookingBulk',
|
||||
target: `POST /admin/expressbooking/bulk${label}`,
|
||||
status: 'complete',
|
||||
stats: `${batch.length} submitted`
|
||||
});
|
||||
} catch (err) {
|
||||
// doormileAxios rejects with the response BODY; the status is attached
|
||||
// as `err.httpStatus`.
|
||||
const status = err.httpStatus;
|
||||
const reason = err.message || 'Request failed';
|
||||
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason })));
|
||||
sourceCalls.push({
|
||||
name: 'createExpressBookingBulk',
|
||||
target: `POST /admin/expressbooking/bulk${label}`,
|
||||
status: 'error',
|
||||
errorMessage: `${status || 'network'} · ${reason}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: created > 0,
|
||||
created,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
batches: batches.length,
|
||||
sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
|
||||
};
|
||||
};
|
||||
142
src/pages/nearle/assistant/customerFlow.js
Normal file
142
src/pages/nearle/assistant/customerFlow.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-customer ||============================== //
|
||||
//
|
||||
// Asks for one field at a time, then shows what it will send and waits for
|
||||
// Submit.
|
||||
//
|
||||
// ---- Why this lives here and not in the router ------------------------------
|
||||
//
|
||||
// A first attempt at this shipped and broke immediately: the operator typed
|
||||
// "create customer", was asked for a phone number, replied "8494948494", and
|
||||
// got "I can't answer that one yet".
|
||||
//
|
||||
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
|
||||
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
|
||||
// routed to the fallback and discarded. Threading a partial draft through the
|
||||
// router's `context` didn't help, because the router had already failed to
|
||||
// choose an intent before the draft was ever consulted.
|
||||
//
|
||||
// So the conversation is owned by the PANEL, which checks for an active flow
|
||||
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
|
||||
// the only arrangement where "8494948494" can't be misread as a question.
|
||||
//
|
||||
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
|
||||
// name and a 10-digit phone are required, everything else is optional and
|
||||
// skippable.
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
|
||||
|
||||
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
|
||||
// operator has no way past an optional field except inventing a value.
|
||||
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
|
||||
|
||||
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
|
||||
|
||||
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
|
||||
// `validate` returns an error string to re-ask with, or null to accept.
|
||||
export const CUSTOMER_STEPS = [
|
||||
{
|
||||
field: 'name',
|
||||
ask: 'What’s the customer’s name?',
|
||||
required: true,
|
||||
apply: (draft, text) => {
|
||||
const [firstname, ...rest] = String(text).trim().split(/\s+/);
|
||||
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
|
||||
},
|
||||
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
required: true,
|
||||
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
|
||||
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
|
||||
// are both accepted rather than rejected on formatting.
|
||||
validate: (text) => {
|
||||
const digits = String(text)
|
||||
.replace(/\D/g, '')
|
||||
.replace(/^91(?=\d{10}$)/, '');
|
||||
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
ask: 'Email address? (say “skip” if there isn’t one)',
|
||||
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
|
||||
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
|
||||
},
|
||||
{
|
||||
field: 'address',
|
||||
ask: 'Address? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
ask: 'City? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'postcode',
|
||||
ask: 'Postcode? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
|
||||
}
|
||||
];
|
||||
|
||||
// Starts the flow, pre-filling anything already said in the opening message —
|
||||
// "create a customer Ramesh 9876543210" should not then ask for the name and
|
||||
// the phone it was just given.
|
||||
export const startCustomerFlow = (text) => {
|
||||
const draft = parseCustomerDraft(text);
|
||||
return advance({ kind: 'createCustomer', step: 0, draft });
|
||||
};
|
||||
|
||||
// Moves to the next step that still needs an answer. Returns either a question
|
||||
// to ask, or the finished proposal.
|
||||
export const advance = (flow) => {
|
||||
let { step } = flow;
|
||||
const { draft } = flow;
|
||||
|
||||
while (step < CUSTOMER_STEPS.length) {
|
||||
const s = CUSTOMER_STEPS[step];
|
||||
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 };
|
||||
}
|
||||
|
||||
// Every step visited. The required check is repeated here rather than
|
||||
// trusted from the walk above, so a skipped-but-required field can never
|
||||
// reach a proposal.
|
||||
const { ok, missing } = validateCustomerDraft(draft);
|
||||
if (!ok) {
|
||||
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
|
||||
}
|
||||
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
|
||||
};
|
||||
|
||||
// Applies one answer. Returns the next question, or the completed proposal, or
|
||||
// a re-ask when the answer didn't validate.
|
||||
export const answerStep = (flow, text) => {
|
||||
const s = CUSTOMER_STEPS[flow.step];
|
||||
if (!s) return advance(flow);
|
||||
|
||||
if (isSkip(text)) {
|
||||
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
|
||||
// Skipped optional field: step past it without writing anything, so the
|
||||
// payload builder drops it rather than sending an empty string.
|
||||
return advance({ ...flow, step: flow.step + 1 });
|
||||
}
|
||||
|
||||
const error = s.validate?.(text);
|
||||
if (error) return { flow, ask: error, done: false, retry: true };
|
||||
|
||||
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
|
||||
};
|
||||
|
||||
// Human-readable summary of what will be sent, for the confirm step.
|
||||
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));
|
||||
83
src/pages/nearle/assistant/flowEngine.js
Normal file
83
src/pages/nearle/assistant/flowEngine.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// ==============================|| Doormile AI — conversational flow engine ||============================== //
|
||||
//
|
||||
// One step-walker, shared by every conversational create (orderFlow.js,
|
||||
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
|
||||
// create became a conversation too — a second copy would have been a third
|
||||
// definition of the same branching rules to keep in sync.
|
||||
//
|
||||
// A step is a plain object:
|
||||
//
|
||||
// id required. Also the draft key the answer lands on.
|
||||
// type 'select' → the panel renders a dropdown (AIFlowStep)
|
||||
// 'rows' → the panel renders the file/paste input
|
||||
// 'text' → answered through the composer
|
||||
// ask the question
|
||||
// when (draft) => boolean. Omitted means always asked. THIS is branching.
|
||||
// options async (draft) => [{ value, label, record? }] — for 'select'
|
||||
// validate (raw, option) => error | null. Re-asks; stores nothing.
|
||||
// resolve async (raw) => { value } | { error }. May fail and re-ask —
|
||||
// geocoding. A value the rest of the flow depends on is never
|
||||
// stored half-resolved.
|
||||
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
|
||||
// from real data and is only ASKED when that fails, with the reason.
|
||||
// apply (draft, value, option) => draft
|
||||
//
|
||||
// A step is skipped when `when` is false OR when `draft[step.id]` is already
|
||||
// set — which is what lets a caller seed the draft (a client login's tenant) or
|
||||
// one step fill several fields (picking an existing customer).
|
||||
|
||||
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
|
||||
|
||||
// Finds the next step that applies and hasn't been answered. Async because a
|
||||
// step may answer itself from the network before we know whether to ask it.
|
||||
export const advanceFlow = async (steps, flow) => {
|
||||
let { step, draft } = flow;
|
||||
|
||||
while (step < steps.length) {
|
||||
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 };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft }, step: s, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
|
||||
};
|
||||
|
||||
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
|
||||
|
||||
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
|
||||
export const answerFlowStep = async (steps, flow, raw, option) => {
|
||||
const s = steps[flow.step];
|
||||
if (!s) return advanceFlow(steps, flow);
|
||||
|
||||
if (s.validate) {
|
||||
const error = s.validate(raw, option);
|
||||
if (error) return { flow, step: s, ask: error, done: false, retry: true };
|
||||
}
|
||||
|
||||
let value = raw;
|
||||
if (s.resolve) {
|
||||
const resolved = await s.resolve(raw);
|
||||
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
|
||||
value = resolved.value;
|
||||
}
|
||||
|
||||
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getCompetitorBranches,
|
||||
getCarrierPricing,
|
||||
getMilerSummary,
|
||||
getBookingTrack,
|
||||
getMilerActivity,
|
||||
trackConsignment,
|
||||
getConsignmentLogs,
|
||||
@@ -31,6 +30,8 @@ import { STATUS } from 'themes/dt/tokens';
|
||||
// intent must have no route to a write.
|
||||
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
|
||||
import { CREATE_ORDER_TRIGGER } from './orderActions';
|
||||
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
|
||||
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
|
||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
|
||||
|
||||
// ==============================|| Doormile Bot — intent catalog ||============================== //
|
||||
@@ -709,6 +710,14 @@ const nameAfterKeyword = (text, keyword) => {
|
||||
// riderLookup just because it contains the word "rider".
|
||||
const LOOKUP_TRIGGER = /\b(?:find|where\s+is|status\s+of|search(?:\s+for)?|lookup)\b/i;
|
||||
|
||||
// Questions ABOUT Doormile or about the assistant itself, rather than about
|
||||
// the data. Kept tight: "how many doormile orders today" mentions the name but
|
||||
// is an orders question, and must not be caught here.
|
||||
// `['’]s|s` covers "what's", "what’s" and the apostrophe-less "whats" people
|
||||
// actually type.
|
||||
const ABOUT_TRIGGER =
|
||||
/\b(?:what|who)(?:['’]s|s|\s+is|\s+are)\s+doormile\b|\b(?:tell\s+me\s+)?about\s+doormile\b|\bdoormile\s*\.\s*com\b|\bwho\s+are\s+you\b|\bwhat\s+(?:can|do)\s+you\s+(?:do|answer|help)\b|\bwhat\s+are\s+you\b/i;
|
||||
|
||||
// Guards against the class of bug found live: "how many riders are active
|
||||
// today" contains the word "active", which is ALSO a valid order status
|
||||
// (in-transit) — statusBreakdown's match used to fire on that word alone and
|
||||
@@ -731,16 +740,30 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
|
||||
const MULTI_SPLIT = /\band\b|,|\+|&/i;
|
||||
|
||||
const INTENTS = [
|
||||
{
|
||||
// Ordered FIRST, ahead of createOrder: "create multiple orders" also
|
||||
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger
|
||||
// has to get first refusal or every bulk request opens the single form.
|
||||
id: 'createBulkOrders',
|
||||
label: 'Create several orders from a sheet or a paste — e.g. "bulk upload orders"',
|
||||
match: (text) => (CREATE_BULK_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
headline: 'Upload a sheet or paste your rows and I’ll create them.',
|
||||
form: { kind: 'createBulkOrders', status: 'open' },
|
||||
sourceCalls: []
|
||||
})
|
||||
},
|
||||
{
|
||||
// Ordered FIRST, above createCustomer: "create an order" contains
|
||||
// "order", which orderLookup / orderQuery / totalOrders all match on.
|
||||
// Like createCustomer this NEVER mutates — it opens a form, and the panel
|
||||
// submits only when the operator presses Create.
|
||||
// Like createCustomer this NEVER mutates — the panel intercepts this and
|
||||
// starts a CONVERSATION (orderFlow.js); the mutation fires only when the
|
||||
// operator presses Create on the priced confirmation at the end.
|
||||
id: 'createOrder',
|
||||
label: 'Create an order — e.g. "create an order"',
|
||||
match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
headline: 'Fill this in and I’ll create the order.',
|
||||
headline: 'Let’s build the order.',
|
||||
form: { kind: 'createOrder', status: 'open' },
|
||||
sourceCalls: []
|
||||
})
|
||||
@@ -785,11 +808,15 @@ const INTENTS = [
|
||||
},
|
||||
{
|
||||
id: 'orderLookup',
|
||||
label: 'Status of a specific order — e.g. "status of order #1234"',
|
||||
label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"',
|
||||
match: (text) => {
|
||||
if (!/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
|
||||
const ref = orderIdFromWords(text);
|
||||
if (!ref) return null;
|
||||
// A STRONG reference (DM-…, #1234) is the whole question — an operator
|
||||
// pasting a booking number shouldn't have to wrap a sentence around it.
|
||||
// A WEAK one (bare digits) still needs an order/booking/status/where
|
||||
// word, or a stray "42" would be read as an order id.
|
||||
if (!ref.strong && !/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
|
||||
return { orderId: ref.id, strong: ref.strong };
|
||||
},
|
||||
run: async ({ orderId, strong }) => {
|
||||
@@ -826,18 +853,21 @@ const INTENTS = [
|
||||
// Resolve the rider to a NAME (the booking only carries an id), and pull
|
||||
// the tracking trail. Both are enrichment: either failing degrades that
|
||||
// one line rather than the whole answer.
|
||||
const [milers, track] = await Promise.all([
|
||||
// A booking carries `appcustomerid`, never the recipient's name — so the
|
||||
// customer store is read to turn it into one. All three are enrichment:
|
||||
// any of them failing costs that one line, not the whole answer.
|
||||
// GET /admin/bookings/:id/track is deliberately NOT called. Its response
|
||||
// shape was never confirmed (express-console-api.md lists it as
|
||||
// written-but-unproven), so it contributed a "Tracking" line nobody could
|
||||
// rely on and an audit entry that reported an error on every order that
|
||||
// simply has no trail yet. Removed on explicit direction — don't add it
|
||||
// back without a confirmed response shape.
|
||||
const [milers, customers] = await Promise.all([
|
||||
found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null),
|
||||
getBookingTrack(found.bookingid).catch(() => null)
|
||||
found.appcustomerid ? getAdminCustomers().catch(() => null) : Promise.resolve(null)
|
||||
]);
|
||||
const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null;
|
||||
|
||||
// GET /admin/bookings/:id/track has no documented response shape
|
||||
// (express-console-api.md lists it as written-but-unproven), so read it
|
||||
// defensively: report only what is unambiguously there, and stay silent
|
||||
// rather than rendering fields that may not mean what their names imply.
|
||||
const steps = Array.isArray(track) ? track : null;
|
||||
const lastStep = steps && steps.length ? steps[steps.length - 1] : null;
|
||||
const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : null;
|
||||
|
||||
const extraCalls = [];
|
||||
if (milers) {
|
||||
@@ -848,21 +878,14 @@ const INTENTS = [
|
||||
stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id'
|
||||
});
|
||||
}
|
||||
extraCalls.push(
|
||||
steps
|
||||
? {
|
||||
name: 'getBookingTrack',
|
||||
target: `/admin/bookings/${found.bookingid}/track`,
|
||||
status: 'complete',
|
||||
stats: `${steps.length} events`
|
||||
}
|
||||
: {
|
||||
name: 'getBookingTrack',
|
||||
target: `/admin/bookings/${found.bookingid}/track`,
|
||||
status: 'error',
|
||||
errorMessage: 'No tracking data returned'
|
||||
}
|
||||
);
|
||||
if (customers) {
|
||||
extraCalls.push({
|
||||
name: 'getAdminCustomers',
|
||||
target: '/admin/customers',
|
||||
status: 'complete',
|
||||
stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id'
|
||||
});
|
||||
}
|
||||
|
||||
const riderLine = found.assignedmileruserid
|
||||
? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${
|
||||
@@ -870,19 +893,63 @@ const INTENTS = [
|
||||
}`
|
||||
: 'Not yet assigned to a rider.';
|
||||
|
||||
const service = found.serviceoptions?.[0];
|
||||
const parcels = found.parcels || [];
|
||||
const addr = (a, pin, city) => [a, city, pin].filter(Boolean).join(', ');
|
||||
|
||||
// Every field that is actually on the record, and nothing that isn't — a
|
||||
// row is omitted rather than rendered as "—", so a blank never reads as
|
||||
// "we checked and it's empty" when it means "this booking has no such
|
||||
// field at all".
|
||||
const items = [
|
||||
{ label: 'Status', meta: `${status}${found.status && found.status !== status ? ` (${found.status})` : ''}` },
|
||||
{ label: 'Rider', meta: riderLine.replace(/^Rider: /, '') },
|
||||
customer || found.appcustomerid
|
||||
? {
|
||||
label: 'Customer',
|
||||
meta: customer
|
||||
? [customer.name || [customer.firstname, customer.lastname].filter(Boolean).join(' '), customer.phone]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: `#${found.appcustomerid} (name unavailable)`
|
||||
}
|
||||
: null,
|
||||
found.pickupaddress ? { label: 'Pickup', meta: addr(found.pickupaddress, found.pickuppincode) } : null,
|
||||
found.deliveryaddress ? { label: 'Drop', meta: addr(found.deliveryaddress, found.deliverypincode, found.deliverycity) } : null,
|
||||
service
|
||||
? {
|
||||
label: 'Service',
|
||||
meta: [service.servicetype, service.estimatedprice != null ? `₹${Number(service.estimatedprice).toFixed(2)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
: null,
|
||||
parcels.length
|
||||
? {
|
||||
label: `Parcel${parcels.length === 1 ? '' : `s (${parcels.length})`}`,
|
||||
meta: parcels
|
||||
.slice(0, 3)
|
||||
.map((p) => [p.itemcategory, p.itemdescription].filter(Boolean).join(' · '))
|
||||
.join(' | ')
|
||||
}
|
||||
: null,
|
||||
found.createdat ? { label: 'Created', meta: parseDoormileTimestamp(found.createdat).format('DD MMM YYYY, hh:mm A') } : null,
|
||||
found.updatedat ? { label: 'Last updated', meta: parseDoormileTimestamp(found.updatedat).format('DD MMM YYYY, hh:mm A') } : null,
|
||||
etaOf(found) ? { label: 'Promised by', meta: parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A') } : null,
|
||||
service?.sladueat ? { label: 'SLA due', meta: parseDoormileTimestamp(service.sladueat).format('DD MMM, hh:mm A') } : null,
|
||||
found.consignmentid ? { label: 'Consignment', meta: `#${found.consignmentid}` } : null,
|
||||
found.bookingsource ? { label: 'Source', meta: found.bookingsource } : null,
|
||||
found.notes ? { label: 'Notes', meta: found.notes } : null
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
headline: `Order ${bookingLabel(found)} is ${status}.`,
|
||||
detail: [
|
||||
riderLine,
|
||||
found.pickupaddress ? `Pickup: ${found.pickupaddress}` : null,
|
||||
found.deliveryaddress ? `Drop: ${found.deliveryaddress}` : null,
|
||||
etaOf(found) ? `Promised by ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}` : null,
|
||||
steps && steps.length
|
||||
? `${steps.length} tracking event${steps.length === 1 ? '' : 's'}${lastStep?.status ? `, latest: ${lastStep.status}` : ''}`
|
||||
: null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
list: { title: 'The full record', numbered: false, items },
|
||||
// Lateness is the one thing worth saying before the rows are read.
|
||||
detail:
|
||||
delayState(found, dayjs()) === 'late'
|
||||
? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.`
|
||||
: undefined,
|
||||
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls]
|
||||
};
|
||||
}
|
||||
@@ -1940,6 +2007,37 @@ const INTENTS = [
|
||||
sourceCalls: [scanCall(scan, `${rows.length} matched`)]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// LAST in the catalog on purpose: every operational intent gets first
|
||||
// refusal, so this can only ever claim a question none of them recognised.
|
||||
// Its trigger is deliberately narrow — a question that merely CONTAINS the
|
||||
// word "doormile" ("how many doormile orders today") must still route to
|
||||
// the intent that can actually count it.
|
||||
id: 'aboutDoormile',
|
||||
label: 'What Doormile is, and what I can answer — e.g. "what is doormile"',
|
||||
match: (text) => (ABOUT_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
// Only what this console demonstrably does. Nothing here is a claim about
|
||||
// the company, its coverage, its pricing or its history — none of that is
|
||||
// in this app, and inventing it would be exactly the failure mode the
|
||||
// whole catalog is built to avoid. doormile.com is where that lives.
|
||||
headline: 'Doormile is the delivery operation this console runs.',
|
||||
detail:
|
||||
'From here you manage orders and deliveries, dispatch riders in batches, run hubs and vehicles, and handle tenants, pricing and reports. I answer questions about that live data — I don’t hold Doormile’s own company information, so for anything beyond day-to-day operations see doormile.com.',
|
||||
list: {
|
||||
title: 'What I can answer',
|
||||
numbered: false,
|
||||
items: [
|
||||
{ label: 'Orders', meta: 'counts by status, batch, tenant or rider · revenue · delays · one order by its number' },
|
||||
{ label: 'Riders', meta: 'how many are active · what a named rider has done' },
|
||||
{ label: 'Fleet', meta: 'hubs, vehicles, tripsheets and exceptions' },
|
||||
{ label: 'Business', meta: 'tenants, customers, pricing, consignments, partners' },
|
||||
{ label: 'Creating', meta: 'a customer, one order, or many from a spreadsheet' }
|
||||
]
|
||||
},
|
||||
sourceCalls: []
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
@@ -2079,6 +2177,31 @@ const answerMultiPart = async (text) => {
|
||||
export async function answerQuestion(text, context = {}) {
|
||||
const normalized = correctTypos(text);
|
||||
|
||||
// ---- Semantic routing (optional) ---------------------------------------
|
||||
//
|
||||
// Tried FIRST, because the regex catalog's weakness is vocabulary, not
|
||||
// logic: "cancellation" not matching `cancel(led)?`, a bare reply matching
|
||||
// nothing, "per day" being dropped. Retrieval fixes the matching problem
|
||||
// without touching how an answer is produced — the intent's own run() still
|
||||
// executes and every number still comes from a live API call.
|
||||
//
|
||||
// Returns null whenever the sidecar is absent, slow, or unsure, in which
|
||||
// case the deterministic matcher below runs exactly as it does today. This
|
||||
// path can only add coverage.
|
||||
const routed = await routeQuestion(normalized);
|
||||
if (isRouteTrustworthy(routed)) {
|
||||
const intent = INTENTS_BY_ID[routed.intentId];
|
||||
// The intent's own match() still extracts the slots — dates, statuses,
|
||||
// tenants, riders. Retrieval decides WHICH question; parsing decides WITH
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Tried BEFORE the single-intent pass: several intents match on a bare
|
||||
// substring ("revenue" anywhere in the text) and their `run` never
|
||||
// returns null, so on a combined question like "orders and revenue
|
||||
@@ -2101,5 +2224,40 @@ export async function answerQuestion(text, context = {}) {
|
||||
if (followUp) return followUp;
|
||||
}
|
||||
|
||||
// ---- Last resort: is this a question about how the console WORKS? -------
|
||||
//
|
||||
// Reached only when no intent produced an answer. "What is CityGate", "why
|
||||
// does dispatch reconcile before commit", "what's the pagination cap" are
|
||||
// real operator questions that no amount of API access can answer — they're
|
||||
// answered by the documentation.
|
||||
//
|
||||
// Passages are returned VERBATIM with their source. There is no generation
|
||||
// step: summarising would need a hosted model (CLAUDE.md §2) and would let a
|
||||
// paraphrase drift from what the doc actually says. The operator reads the
|
||||
// real words and can see which file they came from.
|
||||
const docs = await askDocs(normalized);
|
||||
if (docs?.chunks?.length) {
|
||||
const best = docs.chunks[0];
|
||||
return {
|
||||
headline: best.heading || 'From the documentation',
|
||||
detail: best.text,
|
||||
list:
|
||||
docs.chunks.length > 1
|
||||
? {
|
||||
title: 'Other passages',
|
||||
numbered: false,
|
||||
items: docs.chunks.slice(1).map((c) => ({ label: c.heading || c.source, meta: c.source }))
|
||||
}
|
||||
: undefined,
|
||||
intentId: 'docsAnswer',
|
||||
sourceCalls: docs.chunks.map((c) => ({
|
||||
name: 'console_docs',
|
||||
target: c.source,
|
||||
status: 'complete',
|
||||
stats: `similarity ${c.score}`
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,14 @@ export const executeCreateOrder = async (payload) => {
|
||||
};
|
||||
} catch (err) {
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
const message = err.response?.data?.message || err.message || 'The order could not be created.';
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
const message =
|
||||
status === 404 || status === 405
|
||||
? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
|
||||
: status
|
||||
? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
|
||||
: `${serverMessage || 'The request failed'} — nothing was saved.`;
|
||||
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
|
||||
}
|
||||
};
|
||||
|
||||
267
src/pages/nearle/assistant/orderFlow.js
Normal file
267
src/pages/nearle/assistant/orderFlow.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-order ||============================== //
|
||||
//
|
||||
// One question at a time, mirroring createorder1.js's own field set and using a
|
||||
// DROPDOWN wherever that page uses one — business location, customer, category,
|
||||
// weight, service tier. Free text is only for things that genuinely are free
|
||||
// text (a name, an address, a description).
|
||||
//
|
||||
// Two capabilities the customer flow didn't need:
|
||||
//
|
||||
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
|
||||
// straight to picking from the real customer list, new asks for the fields.
|
||||
// Steps carry a `when` predicate and are skipped when it's false.
|
||||
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
|
||||
// dropdown never shows a stale or invented list.
|
||||
//
|
||||
// The conversation is driven by the PANEL, which intercepts replies before the
|
||||
// router ever sees them — see customerFlow.js for why that matters (a bare
|
||||
// "8494948494" matches no intent and used to be discarded).
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const digits = (t) => String(t || '').replace(/\D/g, '');
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
// ---- steps ------------------------------------------------------------------
|
||||
//
|
||||
// type: 'select' → the panel renders a dropdown from `options(draft)`
|
||||
// 'text' → answered through the composer
|
||||
// when: omitted means always asked
|
||||
export const ORDER_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant is this order for?',
|
||||
// A client login already has its tenant; only Doormile staff choose.
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
ask: 'Which business location is this picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
// The pincode is shown because it decides CityGate — a location outside
|
||||
// the open cities will be refused server-side, and the operator should
|
||||
// see that before choosing rather than after submitting.
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'customerMode',
|
||||
type: 'select',
|
||||
ask: 'Is this an existing customer, or a new one?',
|
||||
options: async () => [
|
||||
{ value: 'existing', label: 'Existing customer' },
|
||||
{ value: 'new', label: 'New customer' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, customerMode: v })
|
||||
},
|
||||
{
|
||||
id: 'existingCustomer',
|
||||
type: 'select',
|
||||
ask: 'Which customer?',
|
||||
when: (d) => d.customerMode === 'existing',
|
||||
options: async () => {
|
||||
const customers = (await getAdminCustomers()) || [];
|
||||
return customers
|
||||
.filter((c) => c.phone)
|
||||
.map((c) => ({
|
||||
value: String(c.appcustomerid ?? c.id),
|
||||
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
|
||||
record: c
|
||||
}));
|
||||
},
|
||||
// Picking an existing customer fills the name and phone, so the two
|
||||
// free-text steps below are skipped by their own `when`.
|
||||
apply: (d, v, option) => ({
|
||||
...d,
|
||||
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
|
||||
customer_phone: digits(option?.record?.phone)
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'customer_name',
|
||||
type: 'text',
|
||||
ask: 'What’s the customer’s name?',
|
||||
when: (d) => d.customerMode === 'new' && !d.customer_name,
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
|
||||
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'customer_phone',
|
||||
type: 'text',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
when: (d) => !d.customer_phone,
|
||||
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
|
||||
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
|
||||
},
|
||||
{
|
||||
id: 'deliveryaddress',
|
||||
type: 'text',
|
||||
ask: 'Where is it being delivered? Give the full address.',
|
||||
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
|
||||
// an address that can't be located is refused here rather than becoming a
|
||||
// booking nothing can dispatch.
|
||||
resolve: async (t) => {
|
||||
const place = await geocodeAddress(String(t).trim()).catch(() => null);
|
||||
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
|
||||
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
|
||||
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
|
||||
});
|
||||
return {
|
||||
value: {
|
||||
...parts,
|
||||
deliverylatitude: place.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place.geometry?.location?.lng?.()
|
||||
}
|
||||
};
|
||||
},
|
||||
apply: (d, v) => ({ ...d, ...v })
|
||||
},
|
||||
{
|
||||
id: 'deliverypincode',
|
||||
type: 'text',
|
||||
ask: 'What’s the delivery pincode?',
|
||||
when: (d) => !d.deliverypincode,
|
||||
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
|
||||
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'itemcategory',
|
||||
type: 'select',
|
||||
ask: 'What kind of parcel is it?',
|
||||
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||
apply: (d, v) => ({ ...d, itemcategory: v })
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'select',
|
||||
ask: 'Roughly how heavy?',
|
||||
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
|
||||
apply: (d, v) => ({ ...d, weight: v })
|
||||
},
|
||||
{
|
||||
id: 'itemdescription',
|
||||
type: 'text',
|
||||
ask: 'Briefly, what’s inside?',
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
|
||||
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
type: 'select',
|
||||
ask: 'How many parcels?',
|
||||
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
|
||||
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
|
||||
},
|
||||
{
|
||||
id: 'finalprice',
|
||||
type: 'text',
|
||||
ask: 'What should the price be? Enter the amount in ₹.',
|
||||
// `auto` answers a step from real data and only falls back to asking. The
|
||||
// quote is stashed either way so the confirmation can show the distance it
|
||||
// measured, and say why it couldn’t price when it couldn’t.
|
||||
auto: async (d) => {
|
||||
const quote = await priceOrder(d);
|
||||
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
|
||||
return {
|
||||
patch: { __quote: quote },
|
||||
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
|
||||
};
|
||||
},
|
||||
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
|
||||
apply: (d, t) => ({ ...d, finalprice: Number(t) })
|
||||
}
|
||||
];
|
||||
|
||||
// Mirrors createorder1.js's own lists so the bot offers the same choices.
|
||||
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
|
||||
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
|
||||
|
||||
// ---- pricing ----------------------------------------------------------------
|
||||
//
|
||||
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
|
||||
// this tenant's own pricing row. Quoted, never invented — if no pricing row
|
||||
// matches, the operator is asked for the amount rather than shown a zero.
|
||||
export const priceOrder = async (draft) => {
|
||||
const tid = draft.tenantid || localStorage.getItem('tenantid');
|
||||
const pricing = (await getAdminPricing()) || [];
|
||||
// tenantid is numeric on the pricing row and a string from localStorage — a
|
||||
// strict comparison here silently priced every order at zero once before.
|
||||
const match = pricing.find((p) => String(p.tenantid) === String(tid));
|
||||
|
||||
const pickup = draft.__pickup;
|
||||
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
|
||||
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: pickup.latitude, longitude: pickup.longitude },
|
||||
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
|
||||
).catch(() => null);
|
||||
|
||||
if (km == null) return { error: 'could not measure the distance' };
|
||||
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
|
||||
|
||||
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
||||
return {
|
||||
km,
|
||||
durationMin: getLastRouteDurationMin(),
|
||||
basePrice: match.baseprice,
|
||||
total: Number(Number(total).toFixed(2))
|
||||
};
|
||||
};
|
||||
|
||||
// ---- engine -----------------------------------------------------------------
|
||||
//
|
||||
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
|
||||
// These wrappers keep the order-specific names the panel and the tests use.
|
||||
|
||||
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
|
||||
|
||||
export const startOrderFlow = () => {
|
||||
// A client login already belongs to a tenant, so its step is skipped — but
|
||||
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
|
||||
// draft is what makes the skip safe.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);
|
||||
66
src/pages/nearle/assistant/ragRouter.js
Normal file
66
src/pages/nearle/assistant/ragRouter.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ==============================|| Doormile AI — semantic routing client ||============================== //
|
||||
//
|
||||
// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
|
||||
// asked. It never returns data — every figure still comes from the intent's own
|
||||
// deterministic run(), through the same typed API functions the pages use.
|
||||
//
|
||||
// The whole module is optional by design:
|
||||
//
|
||||
// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
|
||||
// • sidecar unreachable → disabled for this call, regex matcher
|
||||
// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
|
||||
// • low confidence → not used, regex matcher
|
||||
//
|
||||
// Today's behaviour is the floor. This can raise it, never lower it.
|
||||
|
||||
const BASE = process.env.REACT_APP_AI_URL || '';
|
||||
const ROUTE_TIMEOUT_MS = 400;
|
||||
|
||||
export const isRagEnabled = () => Boolean(BASE);
|
||||
|
||||
// Once the sidecar has failed we stop hammering it on every keystroke-fast
|
||||
// question. Re-armed after a cool-off so a restarted container is picked up
|
||||
// without a page reload.
|
||||
let disabledUntil = 0;
|
||||
const COOL_OFF_MS = 30000;
|
||||
|
||||
const post = async (path, body) => {
|
||||
if (!BASE || Date.now() < disabledUntil) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
// Any failure — offline, timeout, 5xx — is silent by design. The operator
|
||||
// gets the deterministic answer; they should never see plumbing.
|
||||
disabledUntil = Date.now() + COOL_OFF_MS;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns { intentId, confidence, score, margin, alternatives } or null.
|
||||
export const routeQuestion = (text) => post('/route', { text });
|
||||
|
||||
// Documentation passages, verbatim with attribution. No generation step —
|
||||
// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
|
||||
// a paraphrase drift from what the doc actually says.
|
||||
export const askDocs = (text) => post('/ask', { text });
|
||||
|
||||
// A semantic near-miss must never open a create form. Write intents require
|
||||
// high confidence AND corroboration from the deterministic trigger, so the
|
||||
// worst case is that the operator types the phrase the regex already knows.
|
||||
export const isRouteTrustworthy = (routed) => {
|
||||
if (!routed?.intentId) return false;
|
||||
if (routed.isWrite) return routed.confidence === 'high';
|
||||
return routed.confidence === 'high' || routed.confidence === 'medium';
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { MdPersonAddAlt1 } from 'react-icons/md';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
@@ -19,34 +18,32 @@ import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import { PageShell } from 'components/nearle_components/PageLayout';
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
||||
import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
|
||||
import { createAdminCustomer } from 'pages/api/doormileApi';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
import { AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
const BRAND = DT.brand;
|
||||
|
||||
// Same field set as nearle_console_express's createCustomer.js (Location,
|
||||
// Choose Client, Name, Phone, Email, Door No, Address, Location/City/State/
|
||||
// Postcode, Landmark) — but POST /admin/tenantcustomers only persists
|
||||
// { firstname, lastname, phone, email }. There is no address/coordinate
|
||||
// storage anywhere on the new API for a customer record (confirmed against
|
||||
// express-console-api.md — the old jupiter /customers/create body doesn't
|
||||
// exist here at all). The address section below is collected and shown so
|
||||
// the form matches the reference 1:1, but it is NOT sent on submit — see the
|
||||
// note under that section. `tenantid` isn't in the documented POST body
|
||||
// either; it's included speculatively for staff logins (every other create
|
||||
// endpoint on this API takes tenantid explicitly) and may be silently
|
||||
// ignored server-side — unconfirmed, this endpoint isn't in the "confirmed
|
||||
// end-to-end" list.
|
||||
// Writes to POST /admin/customers — the same resource the Customers page
|
||||
// (customers.js) reads via getAdminCustomers(), so a customer created here
|
||||
// shows up there immediately. This used to call createTenantCustomer
|
||||
// (POST /admin/tenantcustomers), a different store with its own id
|
||||
// sequence — a customer created that way never appeared on the Customers
|
||||
// list. See src/pages/nearle/assistant/CLAUDE.md ("Customer creation writes
|
||||
// to /admin/customers") for the full history; the Doormile AI assistant's
|
||||
// executeCreateCustomer was fixed first, this page now matches it.
|
||||
//
|
||||
// This resource carries no tenantid (confirmed against a real
|
||||
// GET /admin/customers response), so there's no tenant picker here — unlike
|
||||
// Tenants/Riders/etc, a customer isn't scoped to a client at creation time.
|
||||
// A real GET /admin/customers response does carry address, doorno, landmark,
|
||||
// suburb, city, state, postcode, latitude and longitude, so the address
|
||||
// section below is sent (empty fields dropped) rather than discarded.
|
||||
const Createcustomer = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loggedInTenantId = localStorage.getItem('tenantid');
|
||||
const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0';
|
||||
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [locaName, setLocoName] = useState('Select Location');
|
||||
const [tid, setTid] = useState(isStaffLogin ? '' : loggedInTenantId);
|
||||
|
||||
const [firstname, setFirstname] = useState('');
|
||||
const [lastname, setLastname] = useState('');
|
||||
@@ -60,13 +57,6 @@ const Createcustomer = () => {
|
||||
const [appLocaLat, setAppLocaLat] = useState();
|
||||
const [appLocaLng, setAppLocaLng] = useState();
|
||||
|
||||
const { data: tenantList = [] } = useQuery({
|
||||
queryKey: ['admin-tenants-picker'],
|
||||
queryFn: getAdminTenants,
|
||||
enabled: isStaffLogin
|
||||
});
|
||||
const tenantOptions = (tenantList || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
|
||||
// LocationAutocomplete only reports back applocationid/locationname — it
|
||||
// caches the full hub list (with lat/lng) in localStorage under a
|
||||
// tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js)
|
||||
@@ -123,8 +113,13 @@ const Createcustomer = () => {
|
||||
const opentoast = (message) =>
|
||||
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
// Empty fields are dropped rather than sent as noise — mirrors
|
||||
// buildCustomerPayload in assistant/actions.js, the other caller of this
|
||||
// same endpoint.
|
||||
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||||
|
||||
const createCustomerMutation = useMutation({
|
||||
mutationFn: (payload) => createTenantCustomer(payload),
|
||||
mutationFn: (payload) => createAdminCustomer(payload),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
enqueueSnackbar('Customer created successfully', {
|
||||
@@ -141,20 +136,28 @@ const Createcustomer = () => {
|
||||
});
|
||||
|
||||
const createprofile = () => {
|
||||
if (isStaffLogin && !tid) {
|
||||
opentoast('Select Tenant');
|
||||
} else if (!firstname) {
|
||||
if (!firstname) {
|
||||
opentoast('Enter Name');
|
||||
} else if (!phone || phone.length !== 10) {
|
||||
opentoast('Enter a valid 10-digit Mobile Number');
|
||||
} else {
|
||||
createCustomerMutation.mutate({
|
||||
firstname,
|
||||
lastname,
|
||||
phone,
|
||||
email,
|
||||
...(isStaffLogin && tid ? { tenantid: +tid } : {})
|
||||
});
|
||||
createCustomerMutation.mutate(
|
||||
clean({
|
||||
firstname,
|
||||
lastname,
|
||||
phone,
|
||||
email,
|
||||
address: pickCust.address,
|
||||
doorno,
|
||||
landmark,
|
||||
suburb: pickCust.suburb,
|
||||
city: pickCust.city,
|
||||
state: pickCust.state,
|
||||
postcode: pickCust.postcode,
|
||||
latitude: startPoint.latitude || undefined,
|
||||
longitude: startPoint.longitude || undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,16 +185,6 @@ const Createcustomer = () => {
|
||||
</Text>
|
||||
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
|
||||
</VStack>
|
||||
{isStaffLogin && (
|
||||
<Selector
|
||||
label="Choose Client"
|
||||
options={tenantOptions}
|
||||
value={tid ? String(tid) : ''}
|
||||
onChange={(v) => setTid(v || '')}
|
||||
placeholder="Choose Client"
|
||||
hasSearch
|
||||
/>
|
||||
)}
|
||||
<TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
|
||||
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
|
||||
<TextInput
|
||||
@@ -210,11 +203,6 @@ const Createcustomer = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
|
||||
The backend doesn't store an address against a customer yet — these fields aren't required and won't be saved
|
||||
on submit.
|
||||
</Text>
|
||||
|
||||
<AddressAutocomplete
|
||||
id="customer-address"
|
||||
label="Address"
|
||||
|
||||
@@ -56,17 +56,39 @@ import StatCard from 'components/nearle_components/StatCard';
|
||||
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
|
||||
import { getTenantCustomers, updateTenantCustomer } from 'pages/api/doormileApi';
|
||||
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
// ============================================================================
|
||||
// GET /admin/customers confirmed live: { appcustomerid, createdat, email,
|
||||
// name, phone, totalbookings } — no city/address/firstname field exists at
|
||||
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
|
||||
// there is no create/delete here by design ("Tenant-scoped through their
|
||||
// bookings" — a customer only exists once they've ordered through a tenant).
|
||||
// This page reads GET /admin/tenantcustomers, NOT /admin/customers.
|
||||
//
|
||||
// Why: /admin/customers cannot be written to. `POST /admin/customers` returns
|
||||
// **405 Method Not Allowed** (confirmed live) — the route exists and POST is
|
||||
// not among its methods. On that resource a customer appears only as a side
|
||||
// effect of a booking ("creates a Guest customer if unknown"). So every create
|
||||
// in this console — this page's own dialog, the sidebar's Create Customer page,
|
||||
// and the assistant — writes to /admin/tenantcustomers, and reading
|
||||
// /admin/customers here meant none of them ever showed up.
|
||||
//
|
||||
// The two stores have separate id sequences: a customer created via the
|
||||
// assistant came back as id 2 while the /admin/customers list ran to id 44.
|
||||
// That is why the edit dialog below had to move to updateTenantCustomer as
|
||||
// well — left on updateAdminCustomer it would PATCH /admin/customers/:id with
|
||||
// a tenant-customer id, which is a 404 at best and edits a DIFFERENT PERSON at
|
||||
// worst.
|
||||
//
|
||||
// A real GET /admin/customers response also disproves the note that used to
|
||||
// live here claiming no address/firstname field exists — it carries firstname,
|
||||
// lastname, address, doorno, landmark, suburb, city, state, postcode, latitude
|
||||
// and longitude (all empty on every live record).
|
||||
//
|
||||
// The accessors below deliberately read BOTH shapes. /admin/customers returns a
|
||||
// composed `name` and `appcustomerid`; tenant customers use firstname/lastname
|
||||
// and a different id field, and Tenants.js is not reliable evidence either (it
|
||||
// writes `phone` but renders `contactno`). Falling back across both means a
|
||||
// field-name difference costs one column, not a table of blanks.
|
||||
//
|
||||
// The edit dialog replicates nearle_console_express's customer edit dialog
|
||||
// field-for-field (Name, Contact, Address, Location, City, State, Postcode,
|
||||
@@ -78,10 +100,11 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
const BRAND = DT.brand;
|
||||
|
||||
const custId = (row) => row.appcustomerid;
|
||||
const custName = (row) => row.name || '—';
|
||||
const custPhone = (row) => row.phone || '—';
|
||||
const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.customerid ?? row.id;
|
||||
const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
|
||||
const custPhone = (row) => row.phone || row.contactno || '—';
|
||||
const custEmail = (row) => row.email || '—';
|
||||
// Tenant customers carry no booking count; 0 is honest rather than blank.
|
||||
const custBookings = (row) => row.totalbookings ?? 0;
|
||||
|
||||
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||
@@ -122,7 +145,7 @@ const Customers = () => {
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||
const [columnSizing, setColumnSizing] = useState({});
|
||||
|
||||
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
|
||||
const { data: customers = [], isLoading } = useQuery({ queryKey: ['tenant-customers'], queryFn: getTenantCustomers });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return customers;
|
||||
@@ -144,11 +167,24 @@ const Customers = () => {
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateAdminCustomer(custId(editRow), form),
|
||||
mutationFn: () => {
|
||||
// Tenant customers store firstname/lastname, but this dialog edits one
|
||||
// "Customer Name" field. Split on the first space so a round-trip keeps
|
||||
// both parts instead of blanking the surname.
|
||||
const [firstname, ...rest] = String(form.name || '')
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
return updateTenantCustomer(custId(editRow), {
|
||||
firstname: firstname || '',
|
||||
lastname: rest.join(' '),
|
||||
phone: form.phone || '',
|
||||
email: form.email || ''
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Customer updated', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tenant-customers'] });
|
||||
setEditRow(null);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to update customer');
|
||||
@@ -159,10 +195,11 @@ const Customers = () => {
|
||||
|
||||
const openEdit = (row) => {
|
||||
setEditRow(row);
|
||||
// Seeded through the accessors so the dialog works on either record shape.
|
||||
setForm({
|
||||
name: row.name || '',
|
||||
phone: row.phone || '',
|
||||
email: row.email || ''
|
||||
name: custName(row) === '—' ? '' : custName(row),
|
||||
phone: custPhone(row) === '—' ? '' : custPhone(row),
|
||||
email: custEmail(row) === '—' ? '' : custEmail(row)
|
||||
});
|
||||
setAddressInput(row.address || '');
|
||||
setPickAddress({
|
||||
|
||||
@@ -7,6 +7,7 @@ dayjs.extend(utc);
|
||||
import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi';
|
||||
import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { BATCHES as SHARED_BATCHES, batchRangeLabel } from 'utils/batchBucket';
|
||||
import {
|
||||
MdOutlineDateRange,
|
||||
MdDirectionsBike,
|
||||
@@ -111,6 +112,10 @@ const BRAND = DT.brand;
|
||||
// the canonical status registry (themes/dt/status.js) key to the
|
||||
// `currentStatus` key the queries use AND to the `batchCounts` key (so the
|
||||
// chip count for the tab is one lookup).
|
||||
// 'other' is NOT in this list — it is appended at render time and only when
|
||||
// rows actually land in it, so the strip doesn't carry a permanently-empty tab.
|
||||
const KNOWN_TAB_STATUSES = ['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped', 'delivered', 'cancelled', 'canceled'];
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ status: 'pending', countKey: 'uncoveredLength' },
|
||||
{ status: 'accepted', countKey: 'assignedLength' },
|
||||
@@ -134,19 +139,26 @@ const KPI_META = [
|
||||
// segments the day there sees the same buckets here. Hours are 24h, half-open
|
||||
// [startHour, endHour) — a delivery at exactly endHour falls into the *next*
|
||||
// batch (or none, if the gap isn't covered).
|
||||
// Windows and ranges come from utils/batchBucket.js — the same source Dispatch
|
||||
// derives its own from. They were three hand-maintained copies of numbers that
|
||||
// must agree; a row bucketed one way here and another on Dispatch is the same
|
||||
// bug twice. Only the presentation (colour, icon) is local.
|
||||
const BATCH_PRESENTATION = {
|
||||
morning: { color: '#0ea5e9', iconKey: 'morning' },
|
||||
afternoon: { color: '#f59e0b', iconKey: 'afternoon' },
|
||||
evening: { color: '#6366f1', iconKey: 'evening' }
|
||||
};
|
||||
|
||||
const BATCH_OPTIONS = [
|
||||
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
|
||||
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
|
||||
{
|
||||
id: 'afternoon',
|
||||
label: 'Afternoon Batch',
|
||||
range: '9 AM to 12:30 PM',
|
||||
color: '#f59e0b',
|
||||
iconKey: 'afternoon',
|
||||
startHour: 9,
|
||||
endHour: 12.5
|
||||
},
|
||||
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
|
||||
...SHARED_BATCHES.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
range: batchRangeLabel(b),
|
||||
startHour: b.startHour,
|
||||
endHour: b.endHour,
|
||||
...BATCH_PRESENTATION[b.id]
|
||||
}))
|
||||
];
|
||||
|
||||
// Auto-pick the batch matching the operator's LOCAL wall-clock hour so the
|
||||
@@ -571,7 +583,14 @@ const Deliveries = () => {
|
||||
isLoading: countSourceIsLoading,
|
||||
refetch: countSourceRefetch
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid],
|
||||
// The trailing 'activity' is the date basis (api.js reads queryKey[11]):
|
||||
// keep a row whose LAST UPDATE falls in the window, not only one created in
|
||||
// it. Scoping a delivery board by creation date hid every order booked
|
||||
// yesterday and picked or delivered today — which is why each tab past
|
||||
// Accepted read 0 while the day's fresh orders filled Pending and Accepted.
|
||||
// The other three callers of fetchDeliveries deliberately keep the old
|
||||
// created-only basis.
|
||||
queryKey: ['fetchdeliveries-batchcounts', appId, userid, 'all', startdate, enddate, 200, '', tenantid, locationid, riderid, 'activity'],
|
||||
queryFn: fetchDeliveries,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
|
||||
});
|
||||
@@ -618,7 +637,9 @@ const Deliveries = () => {
|
||||
return countSourceRows.filter((r) => {
|
||||
if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false;
|
||||
const s = String(r.orderstatus || '').toLowerCase();
|
||||
if (wantStatus && s !== wantStatus) return false;
|
||||
if (wantStatus === 'other') {
|
||||
if (KNOWN_TAB_STATUSES.includes(s)) return false;
|
||||
} else if (wantStatus && s !== wantStatus) return false;
|
||||
if (q) {
|
||||
const hay = [
|
||||
r.deliverycustomer,
|
||||
@@ -817,8 +838,8 @@ const Deliveries = () => {
|
||||
{
|
||||
id: 'orderLocation',
|
||||
accessorFn: (row) => row.orderid,
|
||||
size: 220,
|
||||
minSize: 160,
|
||||
size: 240,
|
||||
minSize: 180,
|
||||
enableSorting: false,
|
||||
meta: { label: 'Order / Location' },
|
||||
header: () => (
|
||||
@@ -826,46 +847,43 @@ const Deliveries = () => {
|
||||
Order / Location
|
||||
</Text>
|
||||
),
|
||||
// Order and delivery are stacked, not side by side. They used to be two
|
||||
// VStacks inside an HStack, and flex items default to `min-width: auto`
|
||||
// — so the order id (`DM-BK-BC87D8B0-30828`, unbreakable and wider than
|
||||
// half the column) refused to shrink and crushed the delivery stack to
|
||||
// zero width. A zero-width cell doesn't hide its text, it wraps it one
|
||||
// character per line, which is what rendered as a vertical strip of
|
||||
// letters. Two 20-character ids never fitted a 220px column; labelling
|
||||
// them and giving each its own line is what actually fixes it, and the
|
||||
// bare "-" separator between them conveyed nothing.
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
// express-console-api.md: Doormile timestamps are IST wall-clock with
|
||||
// no timezone marker. A Z-suffixed response reinterpreted as local
|
||||
// time shifts by 5:30 — showing the wrong time and sometimes the
|
||||
// wrong day. parseDoormileTimestamp strips that, matching
|
||||
// getRowBatchId above.
|
||||
const ordered = parseDoormileTimestamp(r.orderdate);
|
||||
const delivered = parseDoormileTimestamp(r.deliverydate);
|
||||
return (
|
||||
<VStack gap={0} padding={0}>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Tooltip content="Location Name-Suburb">
|
||||
<Text maxLines={1}>{r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname}</Text>
|
||||
</Tooltip>
|
||||
<HStack gap={3}>
|
||||
<VStack gap={0} padding={0}>
|
||||
<Tooltip content="Order Id">
|
||||
<Text maxLines={1}>{r.orderid}</Text>
|
||||
</Tooltip>
|
||||
{/* express-console-api.md: Doormile timestamps are IST
|
||||
wall-clock with no timezone marker — bare dayjs() was
|
||||
still being used here despite this comment saying
|
||||
otherwise (the fix was never actually applied below,
|
||||
only claimed) — a Z-suffixed response reinterprets as
|
||||
local time and shifts 5:30, showing the wrong time
|
||||
(and sometimes the wrong day). parseDoormileTimestamp
|
||||
strips that, matching getRowBatchId above. */}
|
||||
<Tooltip content="Ordered date">
|
||||
<Text type="supporting">{parseDoormileTimestamp(r.orderdate).format('DD/MM/YYYY')}</Text>
|
||||
</Tooltip>
|
||||
<Text type="supporting" color="secondary">
|
||||
{parseDoormileTimestamp(r.orderdate).format('hh:mm A')}
|
||||
</Text>
|
||||
</VStack>
|
||||
-
|
||||
<VStack gap={0} padding={0}>
|
||||
<Tooltip content="Delivery Id">
|
||||
<Text maxLines={1}>{r.deliveryid}</Text>
|
||||
</Tooltip>
|
||||
<Tooltip content="Delivery date">
|
||||
<Text type="supporting">{parseDoormileTimestamp(r.deliverydate).format('DD/MM/YYYY')}</Text>
|
||||
</Tooltip>
|
||||
<Text type="supporting" color="secondary">
|
||||
{parseDoormileTimestamp(r.deliverydate).format('hh:mm A')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Tooltip content="Order Id">
|
||||
<Text maxLines={1}>{r.orderid}</Text>
|
||||
</Tooltip>
|
||||
<Text type="supporting" color="secondary">
|
||||
Ordered {ordered.format('DD/MM/YYYY · hh:mm A')}
|
||||
</Text>
|
||||
|
||||
<Tooltip content="Delivery Id">
|
||||
<Text maxLines={1}>{r.deliveryid}</Text>
|
||||
</Tooltip>
|
||||
<Text type="supporting" color="secondary">
|
||||
Delivery {delivered.format('DD/MM/YYYY · hh:mm A')}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1140,6 +1158,16 @@ const Deliveries = () => {
|
||||
|
||||
const colCount = table.getVisibleLeafColumns().length;
|
||||
|
||||
// The distinct statuses that matched no tab, named so the fix is obvious.
|
||||
const unmappedStatuses = useMemo(() => {
|
||||
const seen = new Set();
|
||||
countSourceRows.forEach((r) => {
|
||||
const s = String(r.orderstatus || '').toLowerCase();
|
||||
if (s && !KNOWN_TAB_STATUSES.includes(s)) seen.add(s);
|
||||
});
|
||||
return [...seen];
|
||||
}, [countSourceRows]);
|
||||
|
||||
// Counts per status, scoped to the selected batch. Keys mirror the legacy
|
||||
// *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical
|
||||
// (countData?.uncoveredLength → batchCounts.uncoveredLength).
|
||||
@@ -1147,6 +1175,7 @@ const Deliveries = () => {
|
||||
const c = {
|
||||
uncoveredLength: 0,
|
||||
assignedLength: 0,
|
||||
otherLength: 0,
|
||||
arrivedLength: 0,
|
||||
pickedLength: 0,
|
||||
activeLength: 0,
|
||||
@@ -1184,7 +1213,12 @@ const Deliveries = () => {
|
||||
case 'canceled':
|
||||
c.cancelLength += 1;
|
||||
break;
|
||||
// Every status this page knows has a case above. Anything else — a
|
||||
// backend enum nobody has mapped yet — used to fall through here AND
|
||||
// fail every tab's equality filter, so the row was counted nowhere and
|
||||
// displayed nowhere: silent data loss, not an empty state.
|
||||
default:
|
||||
c.otherLength += 1;
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -1540,7 +1574,15 @@ const Deliveries = () => {
|
||||
const idx = STATUS_TABS.findIndex((t) => t.status === status);
|
||||
if (idx !== -1) handleChangetab(idx);
|
||||
}}
|
||||
tabs={STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 }))}
|
||||
tabs={[
|
||||
...STATUS_TABS.map((t) => ({ status: t.status, count: batchCounts[t.countKey] ?? 0 })),
|
||||
// Only ever rendered when a status arrived that this page has
|
||||
// no tab for. Its presence is the signal that the mapping in
|
||||
// api.js needs extending — the rows are reachable meanwhile.
|
||||
...(batchCounts.otherLength > 0
|
||||
? [{ status: 'other', label: `Other (${unmappedStatuses.join(', ')})`, count: batchCounts.otherLength }]
|
||||
: [])
|
||||
]}
|
||||
size="sm"
|
||||
hasDivider={false}
|
||||
/>
|
||||
@@ -1781,7 +1823,13 @@ const Deliveries = () => {
|
||||
key={header.id}
|
||||
scope="col"
|
||||
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||
style={{ width: header.getSize(), minWidth: header.column.columnDef.minSize, maxWidth: header.column.columnDef.maxSize || header.getSize(), textAlign: align, position: 'relative' }}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
minWidth: header.column.columnDef.minSize,
|
||||
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||
textAlign: align,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
gap={1}
|
||||
@@ -2113,7 +2161,13 @@ const Deliveries = () => {
|
||||
/>
|
||||
<Selector
|
||||
label="Status"
|
||||
options={['pending', 'accepted', 'started', 'arrived', 'delivered', 'cancelled'].map((s) => ({
|
||||
// 'picked' has to be here even though nothing sets it from
|
||||
// this dialog: the dialog opens with the row's CURRENT status
|
||||
// selected, and Converted_To_Consignment now classifies as
|
||||
// picked — an option list without it would open blank on
|
||||
// exactly the rows this dialog is reachable from (it needs a
|
||||
// consignmentid, which is what being picked means).
|
||||
options={['pending', 'accepted', 'started', 'arrived', 'picked', 'delivered', 'cancelled'].map((s) => ({
|
||||
value: s,
|
||||
label: getStatusMeta(s).label
|
||||
}))}
|
||||
|
||||
@@ -9,20 +9,30 @@ Rules for editing `Dispatch.js`, `Preview.js`, `CompareDataPanel.js`, and `dispa
|
||||
|
||||
Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors them — **the two pages must agree on which batch a given row belongs to**, otherwise the same delivery shows up in one batch on one page and a different batch on the other.
|
||||
|
||||
**The windows live in `utils/batchBucket.js` now.** Dispatch derives `BATCHES_DEFAULT_RAW` from it and `deliveries.js` derives `BATCH_OPTIONS` from it, so the two pages cannot disagree by construction. Only presentation (colour, icon) is page-local.
|
||||
|
||||
```js
|
||||
// BATCHES_DEFAULT_RAW — half-open [startHour, endHour) in LOCAL time, not UTC
|
||||
// utils/batchBucket.js — half-open [startHour, endHour) in LOCAL time, not UTC
|
||||
[
|
||||
{ id: 'morning', startHour: 0, endHour: 8 }, // 12 AM – 8 AM
|
||||
{ id: 'afternoon', startHour: 9, endHour: 12.5 }, // 9 AM – 12:30 PM
|
||||
{ id: 'evening', startHour: 16, endHour: 19 } // 4 PM – 7 PM
|
||||
{ id: 'morning', startHour: 0, endHour: 9 }, // 12 AM – 9 AM
|
||||
{ id: 'afternoon', startHour: 9, endHour: 16 }, // 9 AM – 4 PM
|
||||
{ id: 'evening', startHour: 16, endHour: 24 } // After 4 PM
|
||||
]
|
||||
```
|
||||
|
||||
**Gaps are intentional** (8–9 AM, 12 PM–4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one.
|
||||
**They cover the whole day, and that is load-bearing.** They used to be 0–8 / 9–12.5 / 16–19 — 14.5 of 24 hours, with gaps at 8–9 AM, 12:30–4 PM and after 7 PM, documented here as intentional. They weren't wrong when they were written: they came from jupiter, where bucketing ran on `expecteddeliverytime` and they described **promised delivery slots**, which really did cluster.
|
||||
|
||||
This app buckets on `orderdate` — when the order was *placed* — and orders are placed all day. A booking created at 2:43 PM landed in the 12:30–4 PM gap, belonged to no batch, and disappeared from every batch filter on both pages. On Dispatch, where batches are the primary navigation, that made the order invisible entirely. 40% of the clock was a black hole.
|
||||
|
||||
Each window now runs to the start of the next. **Every start hour is unchanged, so the change is strictly additive** — verified exhaustively minute by minute: zero rows move between batches, 570 minutes that previously had no batch now have one, and no minute belongs to two.
|
||||
|
||||
If you ever move bucketing back to a promised-delivery field, the clustered windows become correct again — but then re-read the table below first, because that field was already tried and rejected for a different reason.
|
||||
|
||||
### Time-field selection (`BATCH_TIME_FIELD`)
|
||||
Fixed at `'created'` → bucket key is `['orderdate']` (the booking's `createdat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
|
||||
|
||||
Note the five `TIME_FIELDS` entries that are now inert: this backend emits only `createdat`, `updatedat` and the SLA estimate, so `acceptedtime` / `starttime` / `arrivaltime` / `pickuptime` / `deliverytime` are always undefined. They're harmless while `selectedTimeField` is a constant — but if the operator-facing dropdown is ever resurrected, five of its eight options would silently bucket nothing.
|
||||
|
||||
This is a constant, not state. The operator-facing time-field dropdown and the slot-hour editor have both been **deleted** (they were commented-out dead JSX whose setters nothing called). Don't resurrect either without re-reading the ban below.
|
||||
|
||||
**Three fields have been tried here. Two were wrong:**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -328,3 +328,24 @@
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Package rows
|
||||
--------------------------------------------------------------------------
|
||||
Each row is a parcel entry on the wire. Bordered rather than card-nested so
|
||||
a four-package order doesn't read as four separate sections.
|
||||
-------------------------------------------------------------------------- */
|
||||
.co-package {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--co-border);
|
||||
border-radius: var(--co-radius);
|
||||
background: var(--co-surface);
|
||||
}
|
||||
|
||||
.co-root .co-package-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-3);
|
||||
}
|
||||
|
||||
@@ -113,6 +113,11 @@ const sortLocations = (locations) =>
|
||||
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
|
||||
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
|
||||
|
||||
// POST /admin/expressbooking's own service tiers. Kept in step with
|
||||
// assistant/orderActions.js's SERVICE_OPTIONS — these are the API's values,
|
||||
// not a UI-invented Standard/Express/Same-Day set.
|
||||
const SERVICE_OPTION_VALUES = ['Normal', 'Fast', 'Superfast'];
|
||||
|
||||
const Createorder1 = () => {
|
||||
const [inputValue2, setInputValue2] = useState('');
|
||||
const [inputValue3, setInputValue3] = useState('');
|
||||
@@ -123,7 +128,17 @@ const Createorder1 = () => {
|
||||
// reason a persistent, visible message instead.
|
||||
const [locationsStatus, setLocationsStatus] = useState('loading');
|
||||
const [collectionamt, setCollectionamt] = useState(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
// Delivery tier. Was hardcoded to 'Normal' in the submit payload — the
|
||||
// operator had no way to book a Fast or Superfast delivery from this page at
|
||||
// all. These three are the API's own service_option values (not the
|
||||
// Standard/Express/Same-Day set a generic form would offer).
|
||||
const [serviceOption, setServiceOption] = useState('Normal');
|
||||
// A parcel entry has NO quantity field (confirmed against
|
||||
// express-console-api.md) — "N items" is expressed as N entries, which is
|
||||
// also how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
|
||||
// So a package list replaces the single category/weight pair: each row
|
||||
// becomes its own parcel, and `count` expands to that many identical entries.
|
||||
const [packages, setPackages] = useState([{ category: '', weight: '', description: '', count: 1 }]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [open4, setOpen4] = useState(false);
|
||||
@@ -315,7 +330,7 @@ const Createorder1 = () => {
|
||||
deliverycity: dropCust.city || '',
|
||||
deliverylatitude: Number(dropCust.latitude) || 0,
|
||||
deliverylongitude: Number(dropCust.longitude) || 0,
|
||||
service_option: 'Normal',
|
||||
service_option: serviceOption,
|
||||
finalprice: +totalCharge.toFixed(2) || 0,
|
||||
notes: otherinstructions || '',
|
||||
// The parcel object has no numeric quantity field (confirmed against
|
||||
@@ -325,11 +340,28 @@ const Createorder1 = () => {
|
||||
// This previously always sent exactly one parcel regardless of what the
|
||||
// operator typed into the Quantity field, so it silently got dropped —
|
||||
// matching "I entered 5, deliveries page shows 1".
|
||||
parcels: Array.from({ length: Math.max(1, Number(quantity) || 1) }, () => ({
|
||||
itemcategory: category || 'General',
|
||||
itemdescription: 'Order',
|
||||
declaredvalue: +totalCharge.toFixed(2) || 0
|
||||
}))
|
||||
// One entry per package, expanded by its count. Falls back to the legacy
|
||||
// single-category behaviour if the operator never touched the package
|
||||
// section, so an existing habit still works.
|
||||
parcels: packages.some((pkg) => pkg.category || pkg.description)
|
||||
? packages.flatMap((pkg) =>
|
||||
Array.from({ length: Math.max(1, Number(pkg.count) || 1) }, () => ({
|
||||
itemcategory: pkg.category || 'General',
|
||||
itemdescription: [pkg.description, pkg.weight].filter(Boolean).join(' · ') || 'Order',
|
||||
declaredvalue: +totalCharge.toFixed(2) || 0
|
||||
}))
|
||||
)
|
||||
: // Nothing entered in the package section — fall back to the single
|
||||
// legacy category chip and one parcel. The old free-text Quantity
|
||||
// field is gone; a package row's `count` is how N items is expressed
|
||||
// now, so there is no separate quantity state to read.
|
||||
[
|
||||
{
|
||||
itemcategory: category || 'General',
|
||||
itemdescription: 'Order',
|
||||
declaredvalue: +totalCharge.toFixed(2) || 0
|
||||
}
|
||||
]
|
||||
};
|
||||
if (!arr.tenantid) {
|
||||
opentoast('Pick a tenant before creating an order', 'warning', 2000);
|
||||
@@ -1172,22 +1204,131 @@ const Createorder1 = () => {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* ============================== || Cash Collect & Quantity || ============================== */}
|
||||
{/* ============================== || Package details || ============================== */}
|
||||
<Card padding={0} elevation="none" className="co-card">
|
||||
<SectionHeader
|
||||
color="#6366f1"
|
||||
icon={<MdInventory2 size={16} />}
|
||||
title="Collection & Quantity"
|
||||
subtitle="Cash to collect on delivery, total items"
|
||||
title="Package Details"
|
||||
subtitle="What is being sent, and how much to collect"
|
||||
action={
|
||||
<Button
|
||||
label="Add another package"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPackages((prev) => [...prev, { category: '', weight: '', description: '', count: 1 }])}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<VStack gap={2} padding={3}>
|
||||
{packages.map((pkg, i) => (
|
||||
// Index keys are safe here: rows are only appended or removed,
|
||||
// never reordered, and each row is fully controlled.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<VStack key={i} className="co-package" gap={2} padding={0}>
|
||||
<HStack justify="between" vAlign="center" gap={2}>
|
||||
<Text className="co-package-label">Package {i + 1}</Text>
|
||||
{packages.length > 1 && (
|
||||
<Button
|
||||
label="Remove"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPackages((prev) => prev.filter((_, j) => j !== i))}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<Grid columns={{ minWidth: 170, repeat: 'fit' }} gap={2}>
|
||||
<Selector
|
||||
label="Category"
|
||||
options={PARCEL_CATEGORIES.map((c) => ({ value: c, label: c }))}
|
||||
value={pkg.category}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, category: v } : r)))}
|
||||
placeholder="Select category"
|
||||
/>
|
||||
<Selector
|
||||
label="Weight"
|
||||
options={WEIGHT_OPTIONS.map((w) => ({ value: w, label: w }))}
|
||||
value={pkg.weight}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, weight: v } : r)))}
|
||||
placeholder="Select weight"
|
||||
/>
|
||||
<TextInput
|
||||
label="Count"
|
||||
value={String(pkg.count)}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, count: v.replace(/\D/g, '') } : r)))}
|
||||
/>
|
||||
</Grid>
|
||||
<TextInput
|
||||
label="Description"
|
||||
value={pkg.description}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, description: v } : r)))}
|
||||
placeholder="2 meal boxes"
|
||||
/>
|
||||
</VStack>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={2}>
|
||||
<Selector
|
||||
label="Delivery type"
|
||||
options={SERVICE_OPTION_VALUES.map((v) => ({ value: v, label: v }))}
|
||||
value={serviceOption}
|
||||
onChange={setServiceOption}
|
||||
/>
|
||||
<TextInput label="Cash Collect" value={String(collectionamt)} onChange={(v) => setCollectionamt(v.replace(/[^\d.]/g, ''))} />
|
||||
<TextInput label="Quantity" value={String(quantity)} onChange={(v) => setQuantity(v.replace(/\D/g, ''))} />
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* ============================== || Order summary || ============================== */}
|
||||
{/* Sticky on desktop, static below 1024px (see CreateOrder.css). Reads
|
||||
straight off form state so it updates as the operator types — there
|
||||
is no second source of truth to drift from. */}
|
||||
<Card padding={0} elevation="none" className="co-card co-summary">
|
||||
<SectionHeader color="#0f172a" icon={<MdCheckCircle size={16} />} title="Order Summary" subtitle="Updates as you fill the form" />
|
||||
<VStack gap={2} padding={3}>
|
||||
<HStack className="co-summary-leg" padding={0} gap={0}>
|
||||
<span className="co-journey-dot" data-end="pickup" />
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text className="co-summary-muted">Pickup</Text>
|
||||
<Text className="co-summary-leg-text">{pickCust.address || '—'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack className="co-summary-leg" padding={0} gap={0}>
|
||||
<span className="co-journey-dot" data-end="drop" />
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text className="co-summary-muted">Drop</Text>
|
||||
<Text className="co-summary-leg-text">{dropCust.address || '—'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Divider />
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Schedule</span>
|
||||
<b>{selectedtime ? `${dayjs(startdate).format('DD MMM')} · ${selectedtime}` : '—'}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Packages</span>
|
||||
<b>
|
||||
{packages.reduce((n, pkg) => n + Math.max(1, Number(pkg.count) || 1), 0)}
|
||||
{packages[0]?.weight ? ` · ${packages[0].weight}` : ''}
|
||||
</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Service</span>
|
||||
<b>{serviceOption}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Distance</span>
|
||||
<b>{showDistance ? `${distance} km${durationMin ? ` · ${durationMin} min` : ''}` : '—'}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-total" padding={0} gap={0}>
|
||||
<span className="co-price-total-label">Estimated total</span>
|
||||
<span className="co-price-total-value">{showDistance ? `₹${Number(totalCharge || 0).toFixed(2)}` : '—'}</span>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* ================================================= || Notes || ================================================= */}
|
||||
<Card padding={0} elevation="none" className="co-card">
|
||||
<SectionHeader
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi';
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import Papa from 'papaparse';
|
||||
import { SHEET_HEADER_MAP, requiredSheetColumns, normalizeHeader, cleanReceiverName } from 'utils/bulkOrderColumns';
|
||||
import * as XLSX from 'xlsx';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -606,30 +607,11 @@ const MultipleOrders = () => {
|
||||
};
|
||||
|
||||
// ============================== CSV / XLSX upload ==============================
|
||||
const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
|
||||
const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
const headerMap = {
|
||||
'pickupdate(yyyy-mmm-dd)': 'date',
|
||||
'sendername*': 'locationname',
|
||||
'senderphone*': 'locationcontact',
|
||||
'senderaddress*': 'locationaddress',
|
||||
'receivername*': 'firstname',
|
||||
receiverphone: 'contactno',
|
||||
'receiveralternatephone*': 'altcontactno',
|
||||
receiverfulladdress: 'address',
|
||||
// Best-effort — no live sample sheet to confirm these are the tenant's
|
||||
// actual column headers. Without them deliverycity/deliverypincode in
|
||||
// the submit payload were always sent blank; if these names are wrong,
|
||||
// behavior is unchanged from before (still blank), not worse.
|
||||
receivercity: 'city',
|
||||
receiverpincode: 'postcode',
|
||||
receiverlatitude: 'latitude',
|
||||
receiverlongitude: 'longitude',
|
||||
'itemdescription*': 'description',
|
||||
Quantity: 'quantity',
|
||||
' Collect Cash': 'collectionamt'
|
||||
};
|
||||
// The map, the header normaliser and the name cleaner now live in
|
||||
// utils/bulkOrderColumns.js so Doormile AI's file upload reads the exact same
|
||||
// sheet this page does. Behaviour here is unchanged — the definitions moved,
|
||||
// they were not rewritten.
|
||||
const headerMap = SHEET_HEADER_MAP;
|
||||
|
||||
const handleFileDirectUpload = (event) => {
|
||||
try {
|
||||
@@ -660,7 +642,7 @@ const MultipleOrders = () => {
|
||||
}
|
||||
return newRow;
|
||||
});
|
||||
const requiredCols = Object.keys(headerMap).filter((k) => k.trim().endsWith('*'));
|
||||
const requiredCols = requiredSheetColumns();
|
||||
const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol)));
|
||||
if (missingRequired.length > 0) {
|
||||
opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000);
|
||||
|
||||
@@ -63,9 +63,8 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import StatusTabs from 'components/nearle_components/StatusTabs';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
|
||||
@@ -931,25 +930,6 @@ const Orders = () => {
|
||||
<PageShell fill>
|
||||
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live />
|
||||
|
||||
{/* Derived from the SAME statusCounts map the tab badges below read,
|
||||
by mapping over ORDERS_STATUS_TABS rather than a second parallel
|
||||
grouping — so this row can never disagree with the tabs. */}
|
||||
<KpiGrid>
|
||||
{ORDERS_STATUS_TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<StatCard
|
||||
key={t.status}
|
||||
title={t.label}
|
||||
value={t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)}
|
||||
icon={<Icon size={20} />}
|
||||
color={t.color}
|
||||
loading={isLoadingGetOrders}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard
|
||||
fill
|
||||
tabs={
|
||||
|
||||
@@ -58,9 +58,18 @@ export const STATUS_META = {
|
||||
// "is this a known alias?" rather than silently falling back.
|
||||
//
|
||||
// The booking enums come from `GET /admin/bookings` (confirmed live — see
|
||||
// orders.js). `converted_to_consignment` is deliberately `accepted` and not
|
||||
// `picked`: it fires when the rider marks pickup COMPLETE, but the operator
|
||||
// workflow on the Orders page treats everything before hand-off as assigned.
|
||||
// orders.js). `converted_to_consignment` stays `accepted` HERE and only here:
|
||||
// this registry renders the badge on the Orders page, whose operator workflow
|
||||
// treats everything before hand-off as assigned.
|
||||
//
|
||||
// The DELIVERIES page classifies the same status as `picked`, in
|
||||
// `mapBookingStatusToDeliveryStatus` (api.js) — deliberately, and the two are
|
||||
// not in conflict. Deliveries tracks the rider's engagement, and
|
||||
// doormile-flow.md §5 is explicit that `pickup-complete` is what converts a
|
||||
// booking into a consignment, so on that page the parcel is in the rider's
|
||||
// hands. Orders tracks the operator's action, where it is still just assigned.
|
||||
// Same booking, two questions, two answers. Don't "fix" one to match the other
|
||||
// without re-reading root CLAUDE.md on why the two taxonomies exist.
|
||||
export const STATUS_ALIASES = {
|
||||
pending_pickup: 'pending',
|
||||
pending_assignment: 'pending',
|
||||
|
||||
@@ -9,13 +9,25 @@ import { parseDoormileTimestamp } from './doormileTimestamp';
|
||||
// to import this (dispatch/CLAUDE.md is explicit that folder isn't touched
|
||||
// casually); this is purely additive.
|
||||
//
|
||||
// Half-open [startHour, endHour) ranges in LOCAL time. Gaps (8–9am,
|
||||
// 12:30pm–4pm, after 7pm) are intentional — a row in a gap belongs to no
|
||||
// batch, not the nearest one.
|
||||
// Half-open [startHour, endHour) ranges in LOCAL time, and they COVER THE
|
||||
// WHOLE DAY. That is the point.
|
||||
//
|
||||
// They used to be 0–8, 9–12.5 and 16–19 — 14.5 of 24 hours, with gaps at
|
||||
// 8–9am, 12:30–4pm and after 7pm. Those windows came from jupiter, where
|
||||
// bucketing ran on `expecteddeliverytime` and they described PROMISED DELIVERY
|
||||
// SLOTS. This app buckets on `orderdate` (when the order was placed) instead,
|
||||
// and orders are placed all day: an order created at 2:43pm fell in the
|
||||
// 12:30–4pm gap, belonged to no batch, and vanished from every batch filter on
|
||||
// both the Deliveries and Dispatch pages. 40% of the clock was a black hole.
|
||||
//
|
||||
// Each window now runs to the start of the next, so every row lands in exactly
|
||||
// one batch. Every START hour is unchanged, which makes this strictly additive:
|
||||
// no row that already had a batch moves to a different one — only rows that had
|
||||
// none gain one.
|
||||
export const BATCHES = [
|
||||
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 8 },
|
||||
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||||
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 19 }
|
||||
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 9 },
|
||||
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 16 },
|
||||
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 24 }
|
||||
];
|
||||
|
||||
export const getBatchForHour = (h) => {
|
||||
@@ -25,6 +37,18 @@ export const getBatchForHour = (h) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Human-readable window, e.g. "12 AM–9 AM" / "After 4 PM". Kept here so the
|
||||
// pages' pickers describe the same ranges they filter by.
|
||||
const clock = (h) => {
|
||||
const whole = Math.floor(h);
|
||||
const mins = Math.round((h - whole) * 60);
|
||||
const suffix = whole >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = whole % 12 === 0 ? 12 : whole % 12;
|
||||
return mins ? `${hour12}:${String(mins).padStart(2, '0')} ${suffix}` : `${hour12} ${suffix}`;
|
||||
};
|
||||
|
||||
export const batchRangeLabel = (b) => (b.endHour >= 24 ? `After ${clock(b.startHour)}` : `${clock(b.startHour)}–${clock(b.endHour)}`);
|
||||
|
||||
// The one field batch bucketing uses across the app: a booking's `orderdate`
|
||||
// (== createdat — see fetchDeliveries in api.js). `assigntime` and
|
||||
// `expecteddeliverytime` were both tried and rejected — see dispatch/
|
||||
|
||||
150
src/utils/bulkOrderColumns.js
Normal file
150
src/utils/bulkOrderColumns.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// ==============================|| Bulk-order sheet columns ||============================== //
|
||||
//
|
||||
// The single definition of what a bulk-order spreadsheet may look like. Two
|
||||
// consumers share it:
|
||||
//
|
||||
// • pages/nearle/orders/multipleOrders.js — the console's bulk-upload page,
|
||||
// where this map originated.
|
||||
// • pages/nearle/assistant/bulkFile.js — Doormile AI's file upload.
|
||||
//
|
||||
// It lives here so a sheet that uploads on the page also uploads in the bot,
|
||||
// permanently. Copying it into the assistant was the alternative and was
|
||||
// rejected for the obvious reason: five pages once carried private STATUS_META
|
||||
// copies that drifted into disagreeing about the same order's label.
|
||||
//
|
||||
// SHEET_HEADER_MAP is reproduced from the page byte for byte, including the `*`
|
||||
// suffix convention (a starred header is required) and the note about which
|
||||
// mappings were never confirmed against a live tenant sheet. Do not "tidy" the
|
||||
// keys — they are the tenant's actual column titles, spaces and all.
|
||||
|
||||
export const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
// A receiver name arrives prefixed with the sheet's own row numbering
|
||||
// ("12. Ravi Kumar") often enough that stripping it is part of parsing.
|
||||
export const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
|
||||
|
||||
export const SHEET_HEADER_MAP = {
|
||||
'pickupdate(yyyy-mmm-dd)': 'date',
|
||||
'sendername*': 'locationname',
|
||||
'senderphone*': 'locationcontact',
|
||||
'senderaddress*': 'locationaddress',
|
||||
'receivername*': 'firstname',
|
||||
receiverphone: 'contactno',
|
||||
'receiveralternatephone*': 'altcontactno',
|
||||
receiverfulladdress: 'address',
|
||||
// Best-effort — no live sample sheet to confirm these are the tenant's
|
||||
// actual column headers. Without them deliverycity/deliverypincode in
|
||||
// the submit payload were always sent blank; if these names are wrong,
|
||||
// behavior is unchanged from before (still blank), not worse.
|
||||
receivercity: 'city',
|
||||
receiverpincode: 'postcode',
|
||||
receiverlatitude: 'latitude',
|
||||
receiverlongitude: 'longitude',
|
||||
'itemdescription*': 'description',
|
||||
Quantity: 'quantity',
|
||||
' Collect Cash': 'collectionamt'
|
||||
};
|
||||
|
||||
// Starred headers are the ones the page reports as missing.
|
||||
export const requiredSheetColumns = () => Object.keys(SHEET_HEADER_MAP).filter((k) => k.trim().endsWith('*'));
|
||||
|
||||
// ---- assistant-side translation ---------------------------------------------
|
||||
//
|
||||
// The page consumes the internal names above (`firstname`, `contactno`, …)
|
||||
// because its row editors were built on them. The assistant's rows are shaped
|
||||
// like a booking payload instead, so the two are bridged here rather than in
|
||||
// either consumer.
|
||||
const INTERNAL_TO_ROW = {
|
||||
firstname: 'customer_name',
|
||||
contactno: 'customer_phone',
|
||||
address: 'deliveryaddress',
|
||||
postcode: 'deliverypincode',
|
||||
city: 'deliverycity',
|
||||
latitude: 'deliverylatitude',
|
||||
longitude: 'deliverylongitude',
|
||||
description: 'itemdescription',
|
||||
quantity: 'quantity'
|
||||
};
|
||||
|
||||
// Headers someone typing their own sheet actually writes. Accepted in addition
|
||||
// to the tenant's official titles, never instead of them.
|
||||
//
|
||||
// `collectionamt` (the sheet's "Collect Cash") is deliberately NOT treated as a
|
||||
// price: it is cash to collect from the recipient, while `finalprice` is what
|
||||
// the delivery costs. Mapping one onto the other would quietly bill the wrong
|
||||
// number on every row.
|
||||
const PLAIN_ALIASES = {
|
||||
customer_name: ['name', 'customername', 'customer', 'recipient', 'recipientname', 'receiver', 'to'],
|
||||
customer_phone: ['phone', 'mobile', 'phonenumber', 'mobilenumber', 'customerphone', 'contact', 'recipientphone'],
|
||||
deliveryaddress: ['address', 'deliveryaddress', 'dropaddress', 'fulladdress', 'destination'],
|
||||
deliverypincode: ['pincode', 'pin', 'postcode', 'zip', 'zipcode', 'deliverypincode'],
|
||||
deliverycity: ['city', 'town', 'deliverycity'],
|
||||
deliverylatitude: ['latitude', 'lat', 'deliverylatitude'],
|
||||
deliverylongitude: ['longitude', 'lng', 'lon', 'deliverylongitude'],
|
||||
itemdescription: ['description', 'item', 'itemdescription', 'contents', 'goods', 'particulars'],
|
||||
itemcategory: ['category', 'itemcategory', 'type'],
|
||||
quantity: ['quantity', 'qty', 'parcels', 'pieces', 'count'],
|
||||
finalprice: ['price', 'amount', 'charge', 'deliverycharge', 'finalprice', 'rate', 'fare'],
|
||||
weight: ['weight', 'kg', 'kgs']
|
||||
};
|
||||
|
||||
// normalised header → assistant row field. Built once; the tenant's official
|
||||
// titles win over a plain alias if a sheet somehow carries both.
|
||||
const ROW_FIELD_BY_HEADER = (() => {
|
||||
const out = {};
|
||||
Object.entries(PLAIN_ALIASES).forEach(([field, headers]) => {
|
||||
headers.forEach((h) => {
|
||||
out[normalizeHeader(h)] = field;
|
||||
});
|
||||
});
|
||||
Object.entries(SHEET_HEADER_MAP).forEach(([sheetHeader, internal]) => {
|
||||
const field = INTERNAL_TO_ROW[internal];
|
||||
if (!field) return;
|
||||
out[normalizeHeader(sheetHeader)] = field;
|
||||
out[normalizeHeader(sheetHeader).replace(/\*+$/, '')] = field;
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// The `*` in a sheet title is a "this one is required" annotation the tenant
|
||||
// types, not part of the column's name — so `Receiver Phone*` and
|
||||
// `ReceiverPhone` are the same column and both must resolve. Only the
|
||||
// assistant's lookup is star-tolerant; normalizeHeader itself is untouched,
|
||||
// because multipleOrders.js derives its missing-required warning from the star
|
||||
// and loosening that would change the page's behaviour.
|
||||
const unstar = (h) => normalizeHeader(h).replace(/\*+$/, '');
|
||||
|
||||
export const rowFieldForHeader = (header) => ROW_FIELD_BY_HEADER[normalizeHeader(header)] || ROW_FIELD_BY_HEADER[unstar(header)] || null;
|
||||
|
||||
// Every field the assistant can fill from a sheet, for the downloadable template.
|
||||
export const TEMPLATE_HEADERS = [
|
||||
'Receiver Name*',
|
||||
'Receiver Phone*',
|
||||
'Receiver Full Address*',
|
||||
'Receiver Pincode*',
|
||||
'Receiver City',
|
||||
'Item Description*',
|
||||
'Quantity',
|
||||
'Price'
|
||||
];
|
||||
|
||||
// ---- one sheet row → one assistant row --------------------------------------
|
||||
//
|
||||
// Unrecognised columns are RETURNED, not dropped silently: an operator whose
|
||||
// price column is titled something unexpected needs to be told it was ignored,
|
||||
// or they'll submit 200 orders priced from a column that was never read.
|
||||
export const mapSheetRow = (raw) => {
|
||||
const row = {};
|
||||
const ignored = [];
|
||||
|
||||
Object.entries(raw || {}).forEach(([header, value]) => {
|
||||
const field = rowFieldForHeader(header);
|
||||
if (!field) {
|
||||
if (String(header || '').trim() && String(value ?? '').trim()) ignored.push(String(header).trim());
|
||||
return;
|
||||
}
|
||||
row[field] = field === 'customer_name' ? cleanReceiverName(value) : value;
|
||||
});
|
||||
|
||||
return { row, ignored };
|
||||
};
|
||||
@@ -31,7 +31,21 @@ doormileAxios.interceptors.response.use(
|
||||
localStorage.clear();
|
||||
window.location.replace('/login');
|
||||
}
|
||||
return Promise.reject(error.response?.data || error);
|
||||
// Reject with the server's own body (callers read `err.message`), but keep
|
||||
// the HTTP status reachable as `err.httpStatus`.
|
||||
//
|
||||
// Without this the status is lost entirely: every caller's `err.response`
|
||||
// is undefined, so a 404 and a 500 and a validation failure all collapse
|
||||
// into the same generic "something went wrong". The assistant's write
|
||||
// paths need to tell "that route doesn't exist" apart from "your body was
|
||||
// wrong", and that distinction only exists in the status code.
|
||||
//
|
||||
// Non-enumerable so nothing that spreads or JSON-stringifies the body
|
||||
// starts carrying it around.
|
||||
const body = error.response?.data;
|
||||
if (body === undefined || body === null) return Promise.reject(error);
|
||||
const payload = typeof body === 'object' ? body : { message: String(body) };
|
||||
return Promise.reject(Object.defineProperty(payload, 'httpStatus', { value: error.response?.status, enumerable: false }));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user