implemenation on the bot

This commit is contained in:
2026-08-19 17:08:45 +05:30
parent bdb21766f2
commit 37ca2352e2
52 changed files with 7074 additions and 3568 deletions

5
.gitignore vendored
View File

@@ -104,3 +104,8 @@ dist
# wincompare file # wincompare file
*.bak *.bak
# Local AI sidecar (dev only)
.chroma/
.cache/
services/ai/node_modules/

47
docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
# Local-only stack for the Doormile AI retrieval sidecar.
#
# This is DEV TOOLING. The production image is still the static nginx build in
# Dockerfile — nothing here is deployed, and the console works with this stack
# absent (the bot falls back to its deterministic matcher when REACT_APP_AI_URL
# is unset or unreachable).
#
# docker compose up -d start Chroma + the sidecar
# npm --prefix services/ai run seed build the collections
#
services:
chroma:
image: chromadb/chroma:latest
container_name: doormile-chroma
ports:
- '8000:8000'
volumes:
# Bind-mounted so embeddings survive a container rebuild. Gitignored.
- ./.chroma:/chroma/chroma
environment:
- IS_PERSISTENT=TRUE
- ANONYMIZED_TELEMETRY=FALSE
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8000/api/v2/heartbeat']
interval: 10s
timeout: 5s
retries: 5
ai-sidecar:
image: node:20
container_name: doormile-ai
working_dir: /app
command: sh -c "npm install --no-audit --no-fund && node index.js"
ports:
- '8787:8787'
volumes:
- ./services/ai:/app
# The MiniLM weights (~25MB) are downloaded once and cached here rather
# than re-fetched on every container start.
- ./.cache/transformers:/app/.cache
environment:
- CHROMA_URL=http://chroma:8000
- PORT=8787
- TRANSFORMERS_CACHE=/app/.cache
depends_on:
chroma:
condition: service_healthy

59
services/ai/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Doormile AI — retrieval sidecar
Semantic intent routing and document Q&A over a local ChromaDB.
**This is dev tooling.** The production image is still the static nginx build.
The console works with this stack absent — the assistant falls back to its
deterministic matcher whenever `REACT_APP_AI_URL` is unset or unreachable.
## Run it
```bash
docker compose up -d # chroma :8000, sidecar :8787
docker compose exec ai-sidecar npm run seed
curl localhost:8787/health
```
Then point the console at it:
```
REACT_APP_AI_URL=http://localhost:8787
```
Leave that unset and nothing changes — the bot behaves exactly as it does today.
## What it does
| Endpoint | Purpose |
|---|---|
| `POST /route` | which intent is this question? + confidence |
| `POST /ask` | which documentation passages answer this? |
| `GET /health` | model + collection counts |
## What it deliberately does not do
- **No operational data is embedded.** No bookings, riders or customers. A
vector store is a snapshot; this data changes by the minute. Every figure the
operator sees still comes from a live API call.
- **No generation.** `/ask` returns source passages verbatim with attribution.
Summarising needs a hosted model — see `assistant/CLAUDE.md` §2.
- **No key.** The embedding model runs in-process. That is what puts this
outside §2's blocker.
## Evaluation
```bash
docker compose exec ai-sidecar npm run eval
```
Runs the **held-out** set in `eval-set.json` — phrasings that appear nowhere in
`phrasings.json` and were not used to tune the thresholds. Retrieval always
looks good against its own seed data, so this is the only number worth quoting.
Ship criteria: ≥80% accuracy and **zero false write routes**.
## After changing anything
Re-run `npm run seed` after editing `phrasings.json`, any indexed markdown, or
the embedding model. Nothing errors if you forget — answers just quietly drift
from the source, which is the worst kind of failure to chase.

View File

@@ -0,0 +1,77 @@
import { ChromaClient } from 'chromadb';
import { MODEL_ID, embed, embedMany } from './embed.js';
// ==============================|| Chroma collections ||============================== //
//
// Two collections, deliberately separate:
//
// intent_examples — ~20 phrasings per intent. Answers "which question is
// this?", never "what is the number?".
// console_docs — chunked markdown. Genuine document Q&A.
//
// NO OPERATIONAL DATA IS EMBEDDED. No bookings, riders or customers ever enter
// the vector store. A vector store is a snapshot and this data changes by the
// minute; every figure still comes from a live API call through the existing
// typed functions. That is the whole reason this design is safe to build.
export const INTENTS_COLLECTION = 'intent_examples';
export const DOCS_COLLECTION = 'console_docs';
const client = new ChromaClient({ path: process.env.CHROMA_URL || 'http://localhost:8000' });
// Chroma would otherwise call its own default embedding function (which
// downloads a different model). We embed ourselves so queries and documents
// are guaranteed to come from the same model.
const noopEmbeddingFunction = { generate: async (texts) => embedMany(texts) };
export const getCollection = async (name) =>
client.getOrCreateCollection({
name,
embeddingFunction: noopEmbeddingFunction,
metadata: {
// Stamped so a model swap is detectable rather than silently degrading
// every score. Re-seed after changing it.
'hnsw:space': 'cosine',
embeddingModel: MODEL_ID
}
});
export const resetCollection = async (name) => {
try {
await client.deleteCollection({ name });
} catch {
// Not present yet — nothing to delete.
}
return getCollection(name);
};
// Chroma returns cosine DISTANCE (0 = identical). Similarity is the useful
// direction for a human-facing confidence, so convert once, here, rather than
// leaving every call site to remember which way round it is.
const toSimilarity = (distance) => 1 - Number(distance);
export const query = async (name, text, topK = 5) => {
const collection = await getCollection(name);
const vector = await embed(text);
const res = await collection.query({ queryEmbeddings: [vector], nResults: topK });
const ids = res.ids?.[0] || [];
return ids.map((id, i) => ({
id,
document: res.documents?.[0]?.[i] || '',
metadata: res.metadatas?.[0]?.[i] || {},
score: toSimilarity(res.distances?.[0]?.[i] ?? 1)
}));
};
export const health = async () => {
await client.heartbeat();
const names = (await client.listCollections()).map((c) => c.name ?? c);
const counts = {};
for (const name of [INTENTS_COLLECTION, DOCS_COLLECTION]) {
if (!names.includes(name)) continue;
// eslint-disable-next-line no-await-in-loop
counts[name] = await (await getCollection(name)).count();
}
return { chroma: 'up', collections: counts };
};

36
services/ai/confidence.js Normal file
View File

@@ -0,0 +1,36 @@
// ==============================|| Confidence ||============================== //
//
// Its own module, not part of index.js, because index.js calls app.listen() at
// module scope — importing it from eval.js would boot a second HTTP server as
// a side effect of running the evaluation.
//
// ---- Why margin and not similarity -----------------------------------------
//
// Cosine similarity is NOT a probability of correctness. A score of 0.87 does
// not mean "87% likely right", and showing it as though it does would be the
// same false precision this assistant avoids everywhere else.
//
// What carries information is the MARGIN between the best and second-best
// match. A wide margin means the question is unambiguous. A narrow one means it
// genuinely could be two things — and that is exactly when the bot should ask
// instead of guessing.
//
// The answer's correctness never comes from this number. It comes from the
// intent's deterministic run() hitting a real endpoint.
// Starting values. Tune from eval.js output, not intuition.
export const HIGH_SCORE = 0.75;
export const HIGH_MARGIN = 0.1;
export const MED_SCORE = 0.6;
export const MED_MARGIN = 0.05;
export const classify = (hits) => {
if (!hits.length) return { confidence: 'low', score: 0, margin: 0 };
const score = hits[0].score;
// With a single hit there is nothing to be ambiguous against, so the margin
// is the score itself rather than a fabricated 0.
const margin = hits.length > 1 ? score - hits[1].score : score;
if (score >= HIGH_SCORE && margin >= HIGH_MARGIN) return { confidence: 'high', score, margin };
if (score >= MED_SCORE && margin >= MED_MARGIN) return { confidence: 'medium', score, margin };
return { confidence: 'low', score, margin };
};

66
services/ai/embed.js Normal file
View File

@@ -0,0 +1,66 @@
import { pipeline, env } from '@xenova/transformers';
// ==============================|| Embeddings ||============================== //
//
// all-MiniLM-L6-v2 running IN PROCESS. No API key, no network call per query,
// no per-call cost.
//
// That is not an incidental choice. assistant/CLAUDE.md §2 blocked LLM work on
// exactly one ground: a hosted model needs a secret key and a static CRA build
// has nowhere to put one. A local embedding model has no key, so this plan is
// outside that blocker. Swapping to a hosted embedding model (text-embedding-3
// -small et al) re-opens §2 and needs its own decision — do not do it quietly.
export const MODEL_ID = 'Xenova/all-MiniLM-L6-v2';
export const DIMENSIONS = 384;
// Weights are cached on disk (mounted in docker-compose) so a container
// restart doesn't re-download 25MB.
env.cacheDir = process.env.TRANSFORMERS_CACHE || './.cache';
// Nothing here should reach the network except the one-time model fetch.
env.allowRemoteModels = true;
let extractor = null;
let loading = null;
// Loaded once, lazily, and shared. Concurrent callers await the same promise
// rather than each triggering their own model load.
const getExtractor = async () => {
if (extractor) return extractor;
if (!loading) {
loading = pipeline('feature-extraction', MODEL_ID).then((p) => {
extractor = p;
return p;
});
}
return loading;
};
export const warmUp = async () => {
await getExtractor();
return { model: MODEL_ID, dimensions: DIMENSIONS };
};
// Mean-pooled + L2-normalised sentence embedding.
//
// Normalisation matters: Chroma's cosine space assumes unit vectors, and
// queries must be embedded exactly the same way documents were. A mismatch
// doesn't error — it silently degrades every score, which is the worst kind of
// bug to chase. `seed` writes the model id into collection metadata so a
// mismatch is at least detectable.
export const embed = async (text) => {
const pipe = await getExtractor();
const output = await pipe(String(text || '').trim(), { pooling: 'mean', normalize: true });
return Array.from(output.data);
};
export const embedMany = async (texts) => {
const out = [];
for (const t of texts) {
// Sequential on purpose: batching MiniLM in-process gives no meaningful
// speed-up at this corpus size (~700 vectors) and makes memory spikier.
// eslint-disable-next-line no-await-in-loop
out.push(await embed(t));
}
return out;
};

77
services/ai/eval-set.json Normal file
View File

@@ -0,0 +1,77 @@
{
"_comment": "HELD-OUT evaluation set. None of these phrasings appear in phrasings.json, and none were used to choose the confidence thresholds. That is the entire point — retrieval will always score well against its own seed data, so this is the only number worth reporting. Written as an operator would speak, not as the regex matchers were written. If you add a case here, do NOT then add it to phrasings.json; that would quietly turn the held-out set into training data.",
"cases": [
{ "text": "how much did we ship today", "expect": "totalOrders" },
{ "text": "did we get many bookings", "expect": "totalOrders" },
{ "text": "whats the tally for today", "expect": "totalOrders" },
{ "text": "seven day totals", "expect": "weekOrders" },
{ "text": "how did the week go", "expect": "weekOrders" },
{ "text": "anything not picked up yet", "expect": "statusBreakdown" },
{ "text": "count the scrapped ones", "expect": "statusBreakdown" },
{ "text": "how many made it to the customer", "expect": "statusBreakdown" },
{ "text": "orders sitting with no rider", "expect": "statusBreakdown" },
{ "text": "is anything running behind", "expect": "delayedOrders" },
{ "text": "show me what missed the window", "expect": "delayedOrders" },
{ "text": "are we late anywhere", "expect": "delayedOrders" },
{ "text": "which sites are struggling", "expect": "delayedOrders" },
{ "text": "money in today", "expect": "revenueTotal" },
{ "text": "what did we bill this week", "expect": "revenueTotal" },
{ "text": "how does this week stack up against last", "expect": "comparisonIntent" },
{ "text": "better or worse than yesterday", "expect": "comparisonIntent" },
{ "text": "spread of orders through the day", "expect": "orderTrend" },
{ "text": "what time are we busiest", "expect": "orderTrend" },
{ "text": "what share get scrapped", "expect": "orderRate" },
{ "text": "how much of our volume completes", "expect": "orderRate" },
{ "text": "run me the numbers for today", "expect": "opsSummary" },
{ "text": "quick rundown please", "expect": "opsSummary" },
{ "text": "how many lads are working", "expect": "riderCounts" },
{ "text": "anyone free to take a job", "expect": "riderCounts" },
{ "text": "whats the driver situation", "expect": "riderCounts" },
{ "text": "how did Kumar get on", "expect": "riderActivity" },
{ "text": "how many drops did Suresh finish", "expect": "riderActivity" },
{ "text": "how many jobs did rider Ali turn down", "expect": "riderActivity" },
{ "text": "which client sends us the most work", "expect": "orderQuery" },
{ "text": "Acme deliveries that completed this week", "expect": "orderQuery" },
{ "text": "who are our clients", "expect": "tenantList" },
{ "text": "count the businesses we serve", "expect": "tenantList" },
{ "text": "what have we got on Acme Foods", "expect": "tenantDetail" },
{ "text": "are the depots all running", "expect": "hubStatus" },
{ "text": "how many depots do we run", "expect": "hubStatus" },
{ "text": "how many bikes are spare", "expect": "vehicleStatus" },
{ "text": "whats sitting idle in the fleet", "expect": "vehicleStatus" },
{ "text": "any failed drops", "expect": "exceptionStatus" },
{ "text": "what problems came up", "expect": "exceptionStatus" },
{ "text": "how many runs are out", "expect": "tripsheetStatus" },
{ "text": "how many people are on our books", "expect": "customerCount" },
{ "text": "whats the state of order 4821", "expect": "orderLookup" },
{ "text": "chase up DM-BK-900 for me", "expect": "orderLookup" },
{ "text": "follow parcel DM-CN-77", "expect": "parcelTrack" },
{ "text": "I want to add someone new", "expect": "createCustomer", "isWrite": true },
{ "text": "put a new client on the system", "expect": "createCustomer", "isWrite": true },
{ "text": "I need to raise a job", "expect": "createOrder", "isWrite": true },
{ "text": "set up a delivery for me", "expect": "createOrder", "isWrite": true }
]
}

79
services/ai/eval.js Normal file
View File

@@ -0,0 +1,79 @@
import { INTENTS_COLLECTION, query } from './collections.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { classify } from './confidence.js';
// Read rather than `import ... assert`: the import-assertion syntax changed
// between Node 20 (`assert`) and Node 22 (`with`), and this has to run on both.
const HERE = path.dirname(fileURLToPath(import.meta.url));
const heldOut = JSON.parse(fs.readFileSync(path.join(HERE, 'eval-set.json'), 'utf8'));
// ==============================|| Routing evaluation ||============================== //
//
// The question this answers is NOT "does retrieval work" — it will always look
// good against the phrasings it was seeded with. It is:
//
// does it route phrasings NOBODY tuned it against?
//
// eval-set.json is deliberately held out: none of these appear in
// phrasings.json, and none were used to pick the confidence thresholds.
//
// Ship criteria (RAG_PLAN.md §7):
// • accuracy and coverage beat the regex baseline
// • ZERO false write routes — a wrong create is the worst failure here,
// the same class as answering "0" when the page shows 19.
const pct = (n, d) => (d ? `${((n / d) * 100).toFixed(1)}%` : '—');
const main = async () => {
const cases = heldOut.cases || [];
let correct = 0;
let answered = 0;
let falseWrite = 0;
const wrong = [];
const latencies = [];
for (const c of cases) {
const started = Date.now();
// eslint-disable-next-line no-await-in-loop
const hits = await query(INTENTS_COLLECTION, c.text, 5);
latencies.push(Date.now() - started);
const { confidence, score, margin } = classify(hits);
const top = hits[0]?.metadata || {};
const routed = confidence === 'low' ? null : top.intentId;
if (routed) answered += 1;
if (routed === c.expect) correct += 1;
else if (routed) wrong.push({ text: c.text, expected: c.expect, got: routed, score: score.toFixed(2), margin: margin.toFixed(2) });
// A non-write phrasing that routes to a write intent is the failure that
// matters most — it would open a create form the operator never asked for.
if (top.isWrite && !c.isWrite && confidence === 'high') falseWrite += 1;
}
latencies.sort((a, b) => a - b);
const p = (q) => latencies[Math.floor(latencies.length * q)] ?? 0;
console.log('\n=== held-out routing evaluation ===');
console.log(`cases ${cases.length}`);
console.log(`accuracy ${correct}/${cases.length} ${pct(correct, cases.length)}`);
console.log(`coverage ${answered}/${cases.length} ${pct(answered, cases.length)} (answered at all)`);
console.log(`false writes ${falseWrite} ${falseWrite === 0 ? '✓' : '✗ MUST BE ZERO'}`);
console.log(`latency p50/p95 ${p(0.5)}ms / ${p(0.95)}ms`);
if (wrong.length) {
console.log('\n--- misroutes ---');
wrong.forEach((w) => console.log(` "${w.text}"\n expected ${w.expected}, got ${w.got} (score ${w.score}, margin ${w.margin})`));
}
const ok = falseWrite === 0 && correct / Math.max(cases.length, 1) >= 0.8;
console.log(`\n${ok ? 'PASS' : 'FAIL'} — threshold: >=80% accuracy and zero false writes\n`);
process.exit(ok ? 0 : 1);
};
main().catch((err) => {
console.error('[eval] failed:', err.message);
process.exit(1);
});

97
services/ai/index.js Normal file
View File

@@ -0,0 +1,97 @@
import express from 'express';
import cors from 'cors';
import { warmUp } from './embed.js';
import { INTENTS_COLLECTION, DOCS_COLLECTION, query, health } from './collections.js';
import { classify } from './confidence.js';
// ==============================|| Doormile AI — retrieval sidecar ||============================== //
//
// Two jobs, and neither of them is "produce a number":
//
// POST /route text -> which intent is this? (+ how sure)
// POST /ask text -> which documentation passages answer this?
//
// The console then runs its OWN deterministic intent code to produce every
// figure. Nothing this service returns is ever read by an operator as data.
const app = express();
app.use(cors());
app.use(express.json({ limit: '64kb' }));
app.get('/health', async (_req, res) => {
try {
const model = await warmUp();
const store = await health();
res.json({ ok: true, model, ...store });
} catch (err) {
res.status(503).json({ ok: false, error: err.message });
}
});
app.post('/route', async (req, res) => {
const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ error: 'text is required' });
try {
const hits = await query(INTENTS_COLLECTION, text, 5);
const { confidence, score, margin } = classify(hits);
// Distinct intents only — five phrasings of the same intent in the top-5
// is a strong signal, not five alternatives to offer the operator.
const seen = new Set();
const alternatives = [];
for (const h of hits) {
const id = h.metadata?.intentId;
if (!id || seen.has(id)) continue;
seen.add(id);
alternatives.push({ intentId: id, score: h.score, example: h.document });
}
return res.json({
intentId: alternatives[0]?.intentId ?? null,
isWrite: Boolean(hits[0]?.metadata?.isWrite),
confidence,
score: Number(score.toFixed(4)),
margin: Number(margin.toFixed(4)),
matchedExample: hits[0]?.document ?? null,
// Only meaningful when confidence is low — that is when the panel should
// offer these as buttons instead of picking one.
alternatives: alternatives.slice(1, 3)
});
} catch (err) {
return res.status(503).json({ error: err.message });
}
});
app.post('/ask', async (req, res) => {
const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ error: 'text is required' });
try {
const hits = await query(DOCS_COLLECTION, text, 4);
// Passages are returned verbatim with attribution. There is no generation
// step: summarising would need a hosted model (CLAUDE.md §2) and would let
// a paraphrase drift from what the document actually says.
res.json({
chunks: hits
.filter((h) => h.score > 0.35)
.map((h) => ({
text: h.document,
source: h.metadata?.source,
heading: h.metadata?.heading,
score: Number(h.score.toFixed(4))
}))
});
} catch (err) {
res.status(503).json({ error: err.message });
}
});
const port = Number(process.env.PORT) || 8787;
app.listen(port, () => {
console.log(`[doormile-ai] listening on :${port}`);
// Pay the model load at boot rather than on an operator's first question.
warmUp()
.then((m) => console.log(`[doormile-ai] model ready: ${m.model} (${m.dimensions}d)`))
.catch((e) => console.error('[doormile-ai] model failed to load:', e.message));
});

18
services/ai/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "doormile-ai-sidecar",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Retrieval sidecar for the Doormile AI assistant — semantic intent routing and document Q&A over a local ChromaDB. Deliberately a separate package: the CRA app's dependency tree and webpack/svgr resolutions must not be touched (root CLAUDE.md §4.3).",
"scripts": {
"start": "node index.js",
"seed": "node seed/run.js",
"eval": "node eval.js"
},
"dependencies": {
"@xenova/transformers": "^2.17.2",
"chromadb": "^1.9.2",
"cors": "^2.8.5",
"express": "^4.19.2"
}
}

View File

@@ -0,0 +1,271 @@
{
"_comment": "Example phrasings per intent, used to seed the intent_examples collection. These are deliberately OPERATOR language, not the developer language the regex matchers were written against — the whole point is to cover the phrasings nobody thought to write a pattern for. Do not add a phrasing here that the intent cannot actually answer.",
"totalOrders": [
"how many orders today",
"how many bookings do we have today",
"order count for today",
"what's today's order volume",
"how many orders came in",
"total orders today",
"how many jobs today",
"how much work came in today",
"give me the order count",
"how many did we get today",
"orders received today",
"number of orders"
],
"weekOrders": [
"how many orders this week",
"orders for the week",
"weekly order count",
"how many orders last week",
"orders this month",
"how many bookings this month",
"order volume this week",
"what did we do last week",
"how many orders in the last 7 days"
],
"statusBreakdown": [
"how many delivered orders today",
"how many cancelled orders",
"how many pending orders",
"how many orders are assigned",
"count of unassigned orders",
"how many orders are out for delivery",
"how many got cancelled today",
"what's still pending",
"how many are waiting to be picked up",
"how many orders are in transit",
"how many did we deliver",
"how many orders got rejected"
],
"orderQuery": [
"delivered orders for Acme this week",
"cancelled orders for Acme today",
"how many orders did rider Suresh deliver today",
"morning batch orders for Acme",
"top 5 tenants by orders this week",
"which tenant has the most orders",
"busiest riders this week",
"pending orders for Acme Foods",
"rank tenants by order volume",
"who delivered the most today"
],
"orderTrend": [
"orders per day this week",
"orders per hour today",
"daily order trend",
"hourly breakdown of orders",
"how are orders spread across the day",
"show me the order trend",
"when do most orders come in",
"orders by day for last week",
"what's our busiest hour"
],
"orderRate": [
"cancellation rate this week",
"what percentage of orders get cancelled",
"delivery success rate",
"what percent were delivered",
"cancellation percentage",
"ratio of cancelled orders",
"how often do orders get cancelled",
"what's our delivery rate"
],
"delayedOrders": [
"which orders are delayed",
"anything running late",
"what's overdue",
"how many orders are late",
"which hubs are experiencing delays",
"anything stuck",
"are we behind on anything",
"show me late deliveries",
"orders past their delivery time",
"what's at risk right now",
"any SLA breaches",
"which orders missed their promise"
],
"batchCount": [
"morning batch orders today",
"how many in the afternoon batch",
"evening batch count",
"orders in the morning wave",
"what's in the afternoon run",
"how many orders in tonight's batch"
],
"revenueTotal": [
"total revenue today",
"how much did we make today",
"revenue this week",
"what were the earnings today",
"total charges for the week",
"how much money came in",
"collections today",
"what's the total value of today's orders"
],
"comparisonIntent": [
"orders today vs yesterday",
"compare this week to last week",
"revenue this week compared to last week",
"how does today compare to yesterday",
"are we up or down on last week",
"this month versus last month"
],
"opsSummary": [
"give me today's operations summary",
"how are we doing today",
"daily overview",
"what's the situation right now",
"summarise today",
"ops snapshot",
"give me the headline numbers",
"how's everything looking",
"today at a glance"
],
"orderLookup": [
"status of order #1234",
"where is order DM-BK-123",
"what happened to order 4821",
"look up booking DM-BK-900",
"check order #55",
"is order 1234 delivered yet",
"who's got order #4821"
],
"parcelTrack": [
"track consignment DM-CN-123",
"where is parcel DM-CN-99",
"track shipment ABC-123",
"scan history for DM-CN-123",
"what's happened to this consignment"
],
"riderCounts": [
"how many riders are active",
"how many riders do we have out",
"rider availability",
"how many are on delivery",
"who's available right now",
"how many riders are free",
"fleet status",
"how many riders are offline"
],
"riderLookup": [
"where is rider Kumar",
"find rider Suresh",
"status of rider Ali",
"look up rider Priya",
"is Kumar online"
],
"riderActivity": [
"how is rider Kumar doing today",
"rider Suresh performance",
"how'd Kumar do today",
"how many did rider Ali complete",
"rider Priya stats",
"how many did Kumar reject",
"how far has rider Suresh travelled",
"activity for rider Kumar"
],
"tenantList": ["how many tenants do we have", "list all tenants", "how many clients", "show me our tenants", "tenant count"],
"tenantCount": [
"orders for Acme today",
"how many orders did Acme place",
"Acme Foods order count",
"how much work from Beta Kitchens"
],
"tenantDetail": [
"tell me about tenant Acme Foods",
"details for Acme",
"what do we know about Beta Kitchens",
"Acme Foods locations",
"show me tenant Acme"
],
"hubStatus": [
"current hub status",
"how many hubs do we have",
"are all hubs active",
"hub overview",
"which hubs are open"
],
"hubLookup": ["status of hub Chennai", "find hub Coimbatore", "look up the Bengaluru hub"],
"vehicleStatus": [
"how many vehicles are available",
"vehicle count",
"how many bikes do we have",
"fleet vehicle status",
"what vehicles are free"
],
"vehicleLookup": ["find vehicle TN01AB1234", "status of vehicle TN37XY9", "look up vehicle KA05MM1"],
"tripsheetStatus": [
"how many tripsheets are dispatched",
"tripsheet count",
"how many trips are running",
"open tripsheets"
],
"exceptionStatus": [
"how many open exceptions",
"any delivery exceptions",
"what's gone wrong",
"exception count",
"failed deliveries"
],
"consignmentStatus": ["how many consignments do we have", "consignment count", "total consignments"],
"customerCount": ["how many customers do we have", "customer count", "how many people have ordered", "total customers"],
"appUserCount": ["how many app users do we have", "staff login count", "how many console users"],
"pricingCount": ["how many pricing rules are configured", "pricing rule count", "how many tariffs do we have"],
"partnerCount": ["how many partners do we have", "partner count", "list our fleet partners"],
"competitorBranchCount": ["how many competitor branches are tracked", "competitor branch count", "competitive intel count"],
"carrierPricingCount": ["how many carrier pricing rules", "carrier tariff count", "carrier pricing entries"],
"createCustomer": [
"create a customer",
"add a new customer",
"register a client",
"I need to add a customer",
"new customer please",
"set up a customer record",
"add customer Ramesh 9876543210"
],
"createOrder": [
"create an order",
"book a delivery",
"place a new order",
"I need to create a booking",
"new delivery please",
"raise an order",
"add a booking"
]
}

148
services/ai/seed/run.js Normal file
View File

@@ -0,0 +1,148 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { INTENTS_COLLECTION, DOCS_COLLECTION, resetCollection } from '../collections.js';
import { embedMany, MODEL_ID } from '../embed.js';
// ==============================|| Seeding ||============================== //
//
// Rebuilds both collections from scratch. Idempotent — safe to re-run any time,
// and it MUST be re-run after changing the embedding model or editing any
// indexed document, or answers drift from the source without any error.
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, '../../..');
// Write intents get a stricter confidence bar in the console (a semantic
// near-miss must never open a create form), so they are flagged here.
const WRITE_INTENTS = new Set(['createOrder', 'createCustomer']);
const DOMAIN = {
orders: ['totalOrders', 'weekOrders', 'statusBreakdown', 'orderQuery', 'orderTrend', 'orderRate', 'delayedOrders', 'batchCount', 'revenueTotal', 'comparisonIntent', 'opsSummary', 'orderLookup', 'parcelTrack'],
riders: ['riderCounts', 'riderLookup', 'riderActivity'],
tenants: ['tenantList', 'tenantCount', 'tenantDetail'],
fleet: ['hubStatus', 'hubLookup', 'vehicleStatus', 'vehicleLookup', 'tripsheetStatus', 'exceptionStatus', 'consignmentStatus'],
admin: ['customerCount', 'appUserCount', 'pricingCount', 'partnerCount', 'competitorBranchCount', 'carrierPricingCount'],
write: ['createCustomer', 'createOrder']
};
const domainOf = (intentId) => Object.entries(DOMAIN).find(([, ids]) => ids.includes(intentId))?.[0] || 'other';
// ---- intent_examples --------------------------------------------------------
const seedIntents = async () => {
const raw = JSON.parse(fs.readFileSync(path.join(HERE, 'phrasings.json'), 'utf8'));
const ids = [];
const documents = [];
const metadatas = [];
for (const [intentId, phrasings] of Object.entries(raw)) {
if (intentId.startsWith('_')) continue;
phrasings.forEach((phrase, i) => {
ids.push(`${intentId}::${String(i).padStart(2, '0')}`);
documents.push(phrase);
metadatas.push({ intentId, domain: domainOf(intentId), isWrite: WRITE_INTENTS.has(intentId) });
});
}
const collection = await resetCollection(INTENTS_COLLECTION);
const embeddings = await embedMany(documents);
await collection.add({ ids, documents, metadatas, embeddings });
return { intents: new Set(metadatas.map((m) => m.intentId)).size, vectors: ids.length };
};
// ---- console_docs -----------------------------------------------------------
//
// Split on markdown headings, then hard-wrap long sections. The heading path is
// prepended to every chunk so a retrieved passage carries its own context —
// without it, a chunk reading "Don't do this" is worse than useless.
const CHUNK_CHARS = 800;
const OVERLAP = 100;
const DOC_SOURCES = [
'express-console-api.md',
'CLAUDE.md',
'src/pages/api/CLAUDE.md',
'src/pages/nearle/assistant/CLAUDE.md',
'src/pages/nearle/assistant/ROADMAP.md',
'src/pages/nearle/assistant/RAG_PLAN.md',
'src/pages/nearle/dispatch/CLAUDE.md',
'src/pages/nearle/orders/CLAUDE.md'
];
const chunkMarkdown = (text, source) => {
const out = [];
const lines = text.split('\n');
let heading = source;
let buffer = [];
const flush = () => {
const body = buffer.join('\n').trim();
buffer = [];
if (!body) return;
for (let i = 0; i < body.length; i += CHUNK_CHARS - OVERLAP) {
const slice = body.slice(i, i + CHUNK_CHARS).trim();
if (slice.length > 60) out.push({ heading, text: `${heading}\n\n${slice}` });
}
};
for (const line of lines) {
if (/^#{1,4}\s/.test(line)) {
flush();
heading = line.replace(/^#+\s*/, '').trim();
continue;
}
buffer.push(line);
}
flush();
return out;
};
const seedDocs = async () => {
const ids = [];
const documents = [];
const metadatas = [];
for (const rel of DOC_SOURCES) {
const abs = path.join(REPO, rel);
if (!fs.existsSync(abs)) {
console.warn(`[seed] skipped missing ${rel}`);
continue;
}
const chunks = chunkMarkdown(fs.readFileSync(abs, 'utf8'), rel);
chunks.forEach((c, i) => {
ids.push(`${rel}::${i}`);
documents.push(c.text);
metadatas.push({ source: rel, heading: c.heading, updatedAt: new Date().toISOString().slice(0, 10) });
});
}
const collection = await resetCollection(DOCS_COLLECTION);
const embeddings = await embedMany(documents);
// Chroma caps how much it will accept in one add; these corpora are small
// but batch anyway so this doesn't become a surprise later.
const BATCH = 200;
for (let i = 0; i < ids.length; i += BATCH) {
// eslint-disable-next-line no-await-in-loop
await collection.add({
ids: ids.slice(i, i + BATCH),
documents: documents.slice(i, i + BATCH),
metadatas: metadatas.slice(i, i + BATCH),
embeddings: embeddings.slice(i, i + BATCH)
});
}
return { sources: DOC_SOURCES.length, vectors: ids.length };
};
const main = async () => {
console.log(`[seed] model: ${MODEL_ID}`);
const intents = await seedIntents();
console.log(`[seed] intent_examples: ${intents.vectors} vectors across ${intents.intents} intents`);
const docs = await seedDocs();
console.log(`[seed] console_docs: ${docs.vectors} chunks from ${docs.sources} sources`);
console.log('[seed] done');
};
main().catch((err) => {
console.error('[seed] failed:', err.message);
process.exit(1);
});

View File

@@ -20,7 +20,7 @@ import { StatusDot } from '@astryxdesign/core/StatusDot';
export default function PageHeader({ title, subtitle, live = false, action }) { export default function PageHeader({ title, subtitle, live = false, action }) {
return ( 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}> <VStack gap={0.5} padding={0} minWidth={0}>
<Heading level={1} maxLines={1}> <Heading level={1} maxLines={1}>
{title} {title}

View File

@@ -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 // 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 — // silently, which made every page-scoped rule fail to match with no error —
// the styles simply never applied. // the styles simply never applied.
export function PageShell({ children, gap = 6, fill = false, className }) { export function PageShell({ children, gap = 4, fill = false, className }) {
return ( return (
<> <>
{fill && ( {fill && (
@@ -105,10 +105,10 @@ PageShell.propTypes = {
// `max` caps the column count so 3 cards don't each become 400px wide on an // `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. // 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; const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
return ( 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} {children}
</Grid> </Grid>
); );
@@ -117,6 +117,7 @@ export function KpiGrid({ children, minWidth = 240, max }) {
KpiGrid.propTypes = { KpiGrid.propTypes = {
children: PropTypes.node, children: PropTypes.node,
minWidth: PropTypes.number, minWidth: PropTypes.number,
gap: PropTypes.number,
max: PropTypes.number max: PropTypes.number
}; };

View File

@@ -7,53 +7,42 @@ import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Skeleton } from '@astryxdesign/core/Skeleton'; import { Skeleton } from '@astryxdesign/core/Skeleton';
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== // // ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
// Clean metric card: muted eyebrow label, large tabular value, a single // Modern, space-efficient KPI tile:
// soft-tinted rounded tile holding the icon (with a faint matching ring for // - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
// depth). A low resting elevation lifts the tile off the page instead of // - Soft ambient accent gradient tint based on semantic status color.
// reading as a flat bordered box. No coloured top-stripe, no rainbow — // - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
// colour appears only in the icon tile so a row of cards reads calm and // - Micro-hover interaction (subtle lift + shadow depth).
// corporate.
// //
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}. // `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
// `color` is a hex accent (defaults to brand red); `caption` is the small // `color` is a hex accent (defaults to brand black); `caption` is optional supporting text.
// 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.
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) { export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
return ( return (
<Card height="100%" padding={2} elevation="low"> <Card
<HStack justify="between" vAlign="center" gap={2}> height="100%"
<VStack gap={0.5} padding={0} minWidth={0} width="100%"> padding={1.5}
<Text type="supporting" size="xsm" weight="semibold" maxLines={1}> elevation="none"
{title} className="dt-stat-card"
</Text> style={{
{loading ? ( position: 'relative',
<Skeleton width={60} height={26} radius={2} /> background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
) : ( border: `1px solid ${color}24`,
<Text size="xl" weight="bold" display="block" maxLines={1} hasTabularNumbers> borderRadius: 'var(--radius-container, 6px)',
{value} boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
</Text> cursor: 'default'
)} }}
{caption && ( >
<Text type="supporting" size="sm" display="block"> <HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
{caption}
</Text>
)}
</VStack>
{icon && ( {icon && (
<Center <Center
width={36} width={34}
height={36} height={34}
className="dt-stat-icon"
style={{ style={{
flexShrink: 0, flexShrink: 0,
borderRadius: 'var(--radius-element)', borderRadius: 'var(--radius-element, 6px)',
backgroundColor: `${color}16`, backgroundColor: `${color}14`,
color, color,
border: `1px solid ${color}2A` border: `1px solid ${color}2A`
}} }}
@@ -61,6 +50,51 @@ export default function StatCard({ title, value, icon, color = '#000000', captio
{icon} {icon}
</Center> </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> </HStack>
</Card> </Card>
); );

View File

@@ -52,3 +52,22 @@ body {
*::-webkit-scrollbar-corner { *::-webkit-scrollbar-corner {
background-color: transparent; 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);
}

View File

@@ -46,9 +46,14 @@ import {
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS // 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 // comment) are confirmed; the rest of this mapping is still a best-effort
// guess. Unmapped statuses pass through lowercased, which the page's own // guess. Unmapped statuses pass through lowercased, which the page's own
// fallback renders as an "unknown" badge rather than crashing. There's no // fallback renders as an "unknown" badge rather than crashing, AND the
// confirmed equivalent for arrived/picked/skipped at all, so those tabs will // Deliveries page now surfaces those rows under an "Other" tab that names the
// show a 0 count until the real enum is confirmed. // 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'. // Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'.
// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler // 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'. // 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 // 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. // 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 = { const BOOKING_STATUS_TO_DELIVERY_STATUS = {
pending_pickup: 'pending', pending_pickup: 'pending',
miler_assigned: 'pending', miler_assigned: 'pending',
pickup_scheduled: 'accepted', pickup_scheduled: 'accepted',
converted_to_consignment: 'accepted', converted_to_consignment: 'picked',
out_for_delivery: 'active', out_for_delivery: 'active',
delivered: 'delivered', delivered: 'delivered',
cancelled: 'cancelled' cancelled: 'cancelled'
@@ -135,7 +149,10 @@ export const getRiderPeriodicLogs = async (userid) => {
// seeing every hub, unchanged. Fails open (full list) whenever the tenant's // 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 // city can't be determined or nothing matches, rather than ever locking an
// operator out with an empty picker. // operator out with an empty picker.
const normCity = (s) => String(s || '').trim().toLowerCase(); const normCity = (s) =>
String(s || '')
.trim()
.toLowerCase();
export const fetchAppLocations = async () => { export const fetchAppLocations = async () => {
try { try {
@@ -202,7 +219,16 @@ export const fetchPercentageData = async ({ queryKey }) => {
}; };
} catch (err) { } catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000); 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 // jupiter2doormile.md comparison). Falls back to matching the rider's NAME
// (also echoed by the solver, see flattenRiders' rider_name) against each // (also echoed by the solver, see flattenRiders' rider_name) against each
// miler's displayname/authname — the only other correlatable field. // 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) => { export const buildMilerLookup = (milers) => {
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m])); 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)) const matchedVia = lookup.byUserId.has(String(riderUserId))
? 'userid' ? 'userid'
: lookup.byProfileId.has(String(riderUserId)) : lookup.byProfileId.has(String(riderUserId))
? 'milerprofileid' ? 'milerprofileid'
: lookup.byName.has(normMilerName(riderName)) : lookup.byName.has(normMilerName(riderName))
? 'name' ? 'name'
: null; : null;
const rider = const rider =
lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName)); lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName));
return rider?.milerprofileid ? { rider, matchedVia } : null; return rider?.milerprofileid ? { rider, matchedVia } : null;
@@ -407,7 +436,11 @@ export const finalCreatedeliveries = async (deliveryData) => {
realBookingIds = new Set(realBookings.map((b) => String(b.bookingid))); realBookingIds = new Set(realBookings.map((b) => String(b.bookingid)));
logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`); logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`);
} catch (err) { } 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( const results = await Promise.allSettled(
@@ -464,11 +497,7 @@ export const finalCreatedeliveries = async (deliveryData) => {
if (failed.length) { if (failed.length) {
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000); OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
} }
const resolvedMilerProfileIds = [ const resolvedMilerProfileIds = [...new Set(results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid))];
...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 }; return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds };
}; };
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== // // ==============================|| 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 // 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 // silent 400 or, worse, a silently-ignored filter), so the range is applied
// client-side below, after the rows are normalised. // 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 // Unlike the 3 joins below (customers/milers/tenants — each individually
// guarded so a failed join just degrades a display field, not the whole // 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 // 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 // A missing/blank bound means "unbounded on that side", which preserves the
// old behaviour for any caller that doesn't pass real dates. // 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) => { const inRange = (row) => {
if (!startdate && !enddate) return true; if (!startdate && !enddate) return true;
const t = parseDoormileTimestamp(row.orderdate); const days = [dayOf(row.orderdate), dateBasis === 'activity' ? dayOf(row.assigntime) : null].filter(Boolean);
if (!t.isValid()) return false; if (!days.length) return false;
const day = t.format('YYYY-MM-DD'); return days.some((day) => (!startdate || day >= String(startdate)) && (!enddate || day <= String(enddate)));
if (startdate && day < String(startdate)) return false;
if (enddate && day > String(enddate)) return false;
return true;
}; };
return { return {
@@ -815,9 +857,7 @@ export const getallridersummary = async () => {
// silently inflating one of the two new buckets. // silently inflating one of the two new buckets.
const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length; 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 available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length;
const onDelivery = milers.filter((m) => const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())
).length;
return { total: milers.length, active, inactive: milers.length - active, available, onDelivery }; return { total: milers.length, active, inactive: milers.length - active, available, onDelivery };
} catch (err) { } catch (err) {
OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000); OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000);

View File

@@ -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). - `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT. - `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. - `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. - `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
- `pageContext.js` — route → context label + suggested questions. - `pageContext.js` — route → context label + suggested questions.
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`). - **`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. - **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. - **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. - **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. - **`--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. - **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. 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". - **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. - **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. - **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. - **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 ### 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 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.)
- 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.
**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 ## 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. - **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. - **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. - **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.

View File

@@ -44,7 +44,6 @@
--dai-duration: 240ms; --dai-duration: 240ms;
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1); --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Scrim — deliberately light. The dashboard underneath must stay readable; Scrim — deliberately light. The dashboard underneath must stay readable;
this is a layering cue, not a modal blackout. this is a layering cue, not a modal blackout.
@@ -57,11 +56,9 @@
opacity: 0; opacity: 0;
transition: opacity var(--dai-duration) var(--dai-ease); transition: opacity var(--dai-duration) var(--dai-ease);
} }
.dai-scrim[data-open='true'] { .dai-scrim[data-open='true'] {
opacity: 1; opacity: 1;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Panel Panel
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -87,16 +84,13 @@
transform var(--dai-duration) var(--dai-ease), transform var(--dai-duration) var(--dai-ease),
opacity var(--dai-duration) var(--dai-ease); opacity var(--dai-duration) var(--dai-ease);
} }
.dai-panel[data-open='true'] { .dai-panel[data-open='true'] {
transform: translateX(0); transform: translateX(0);
opacity: 1; opacity: 1;
} }
.dai-panel:focus { .dai-panel:focus {
outline: none; outline: none;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Header Header
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -105,7 +99,6 @@
padding: 14px 12px 12px 14px; padding: 14px 12px 12px 14px;
border-bottom: 1px solid var(--dai-border); border-bottom: 1px solid var(--dai-border);
} }
.dai-root .dai-title { .dai-root .dai-title {
font-size: 15px; font-size: 15px;
font-weight: 650; font-weight: 650;
@@ -113,13 +106,11 @@
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--dai-text); color: var(--dai-text);
} }
.dai-root .dai-subtitle { .dai-root .dai-subtitle {
font-size: 12px; font-size: 12px;
line-height: 1.3; line-height: 1.3;
color: var(--dai-text-muted); color: var(--dai-text-muted);
} }
/* The AI mark. A soft gradient orb — not a robot face. */ /* The AI mark. A soft gradient orb — not a robot face. */
.dai-root .dai-spark { .dai-root .dai-spark {
display: inline-flex; display: inline-flex;
@@ -127,27 +118,34 @@
justify-content: center; justify-content: center;
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 999px; border-radius: 999px;
color: #ffffff; background: var(--dai-surface);
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to)); overflow: hidden;
box-shadow: 0 0 0 3px var(--dai-ai-glow); }
/* 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'] { .dai-root .dai-spark[data-size='sm'] {
width: 22px;
height: 22px;
}
.dai-root .dai-spark[data-size='md'] {
width: 30px; width: 30px;
height: 30px; height: 30px;
} }
.dai-root .dai-spark[data-size='md'] {
.dai-root .dai-spark[data-size='lg'] { width: 40px;
width: 44px; height: 40px;
height: 44px; }
box-shadow: 0 0 0 6px var(--dai-ai-glow); .dai-root .dai-spark[data-size='lg'] {
width: 56px;
height: 56px;
} }
/* Live indicator — subtle, not a large pill. */ /* Live indicator — subtle, not a large pill. */
.dai-root .dai-live { .dai-root .dai-live {
display: inline-flex; display: inline-flex;
@@ -161,7 +159,6 @@
background: rgba(16, 185, 129, 0.08); background: rgba(16, 185, 129, 0.08);
white-space: nowrap; white-space: nowrap;
} }
.dai-root .dai-live-dot { .dai-root .dai-live-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -169,7 +166,6 @@
background: var(--dai-live); background: var(--dai-live);
animation: dai-pulse 2.4s ease-in-out infinite; animation: dai-pulse 2.4s ease-in-out infinite;
} }
@keyframes dai-pulse { @keyframes dai-pulse {
0%, 0%,
100% { 100% {
@@ -181,7 +177,6 @@
transform: scale(0.85); transform: scale(0.85);
} }
} }
/* Page-context strip — "Orders · Today · All locations" */ /* Page-context strip — "Orders · Today · All locations" */
.dai-root .dai-context { .dai-root .dai-context {
flex: 0 0 auto; flex: 0 0 auto;
@@ -194,7 +189,120 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 Scroll region
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -225,141 +333,24 @@
background: transparent; background: transparent;
} }
/* -------------------------------------------------------------------------- .dai-scroll-wrap {
Welcome state position: relative;
-------------------------------------------------------------------------- */
.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; flex: 1 1 auto;
font-size: 13px; min-height: 0;
line-height: 1.35; 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 { .dai-root .dai-thread {
padding: 16px 14px 8px; 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; animation: dai-enter 220ms var(--dai-ease) both;
} }
@@ -374,7 +365,6 @@
} }
} }
/* User — compact, right aligned, brand surface. */
.dai-root .dai-msg-user { .dai-root .dai-msg-user {
max-width: 82%; max-width: 82%;
margin-left: auto; margin-left: auto;
@@ -388,7 +378,6 @@
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* Assistant — no bubble. Text sits on the panel surface. */ /* Assistant — no bubble. Text sits on the panel surface. */
.dai-root .dai-msg-ai { .dai-root .dai-msg-ai {
font-size: 13.5px; font-size: 13.5px;
@@ -396,36 +385,30 @@
color: var(--dai-text); color: var(--dai-text);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.dai-root .dai-msg-ai-detail { .dai-root .dai-msg-ai-detail {
font-size: 12.5px; font-size: 12.5px;
line-height: 1.55; line-height: 1.55;
color: var(--dai-text-secondary); color: var(--dai-text-secondary);
white-space: pre-line; white-space: pre-line;
} }
.dai-root .dai-msg-name { .dai-root .dai-msg-name {
font-size: 11.5px; font-size: 11.5px;
font-weight: 600; font-weight: 600;
color: var(--dai-text-secondary); color: var(--dai-text-secondary);
} }
.dai-root .dai-msg-footer { .dai-root .dai-msg-footer {
font-size: 11px; font-size: 11px;
color: var(--dai-text-muted); color: var(--dai-text-muted);
} }
/* Copy button — only revealed on hover of the message row. */ /* Copy button — only revealed on hover of the message row. */
.dai-root .dai-msg-actions { .dai-root .dai-msg-actions {
opacity: 0; opacity: 0;
transition: opacity 140ms ease; transition: opacity 140ms ease;
} }
.dai-root .dai-msg-row:hover .dai-msg-actions, .dai-root .dai-msg-row:hover .dai-msg-actions,
.dai-root .dai-msg-row:focus-within .dai-msg-actions { .dai-root .dai-msg-row:focus-within .dai-msg-actions {
opacity: 1; opacity: 1;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Structured metrics Structured metrics
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -435,14 +418,12 @@
gap: 6px; gap: 6px;
width: 100%; width: 100%;
} }
.dai-root .dai-stat { .dai-root .dai-stat {
padding: 9px 10px; padding: 9px 10px;
border: 1px solid var(--dai-border); border: 1px solid var(--dai-border);
border-radius: 5px; border-radius: 5px;
background: var(--dai-surface-alt); background: var(--dai-surface-alt);
} }
.dai-root .dai-stat-value { .dai-root .dai-stat-value {
font-size: 19px; font-size: 19px;
font-weight: 650; font-weight: 650;
@@ -450,12 +431,10 @@
letter-spacing: -0.02em; letter-spacing: -0.02em;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.dai-root .dai-stat-label { .dai-root .dai-stat-label {
font-size: 11px; font-size: 11px;
color: var(--dai-text-muted); color: var(--dai-text-muted);
} }
/* Headline metric — the "primary number is large" rule. */ /* Headline metric — the "primary number is large" rule. */
.dai-root .dai-metric-value { .dai-root .dai-metric-value {
font-size: 28px; font-size: 28px;
@@ -465,12 +444,10 @@
color: var(--dai-text); color: var(--dai-text);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.dai-root .dai-metric-label { .dai-root .dai-metric-label {
font-size: 12px; font-size: 12px;
color: var(--dai-text-secondary); color: var(--dai-text-secondary);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Typing / loading Typing / loading
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -480,7 +457,6 @@
gap: 4px; gap: 4px;
height: 18px; height: 18px;
} }
.dai-root .dai-typing span { .dai-root .dai-typing span {
width: 5px; width: 5px;
height: 5px; height: 5px;
@@ -488,15 +464,12 @@
background: var(--dai-text-muted); background: var(--dai-text-muted);
animation: dai-bounce 1.3s ease-in-out infinite; animation: dai-bounce 1.3s ease-in-out infinite;
} }
.dai-root .dai-typing span:nth-child(2) { .dai-root .dai-typing span:nth-child(2) {
animation-delay: 0.16s; animation-delay: 0.16s;
} }
.dai-root .dai-typing span:nth-child(3) { .dai-root .dai-typing span:nth-child(3) {
animation-delay: 0.32s; animation-delay: 0.32s;
} }
@keyframes dai-bounce { @keyframes dai-bounce {
0%, 0%,
70%, 70%,
@@ -509,7 +482,6 @@
transform: translateY(-3px); transform: translateY(-3px);
} }
} }
.dai-root .dai-shimmer { .dai-root .dai-shimmer {
height: 9px; height: 9px;
border-radius: 999px; border-radius: 999px;
@@ -517,7 +489,6 @@
background-size: 400% 100%; background-size: 400% 100%;
animation: dai-shimmer 1.5s ease infinite; animation: dai-shimmer 1.5s ease infinite;
} }
@keyframes dai-shimmer { @keyframes dai-shimmer {
from { from {
background-position: 100% 50%; background-position: 100% 50%;
@@ -526,7 +497,6 @@
background-position: 0 50%; background-position: 0 50%;
} }
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
States (error / empty) States (error / empty)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -536,19 +506,16 @@
border-radius: 5px; border-radius: 5px;
background: var(--dai-surface-alt); background: var(--dai-surface-alt);
} }
.dai-root .dai-state-title { .dai-root .dai-state-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--dai-text); color: var(--dai-text);
} }
.dai-root .dai-state-body { .dai-root .dai-state-body {
font-size: 12.5px; font-size: 12.5px;
line-height: 1.5; line-height: 1.5;
color: var(--dai-text-secondary); color: var(--dai-text-secondary);
} }
.dai-root .dai-state-icon { .dai-root .dai-state-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -558,7 +525,6 @@
border-radius: 5px; border-radius: 5px;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Jump-to-latest Jump-to-latest
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -581,24 +547,13 @@
cursor: pointer; cursor: pointer;
z-index: 2; z-index: 2;
} }
.dai-jump:hover { .dai-jump:hover {
background: var(--dai-surface-alt); background: var(--dai-surface-alt);
} }
.dai-jump:focus-visible { .dai-jump:focus-visible {
outline: 2px solid var(--dai-accent); outline: 2px solid var(--dai-accent);
outline-offset: 2px; outline-offset: 2px;
} }
.dai-scroll-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Composer Composer
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -608,7 +563,6 @@
border-top: 1px solid var(--dai-border); border-top: 1px solid var(--dai-border);
background: var(--dai-surface); background: var(--dai-surface);
} }
.dai-root .dai-composer { .dai-root .dai-composer {
border: 1px solid var(--dai-border-strong); border: 1px solid var(--dai-border-strong);
border-radius: 5px; border-radius: 5px;
@@ -619,12 +573,10 @@
border-color 140ms ease, border-color 140ms ease,
box-shadow 140ms ease; box-shadow 140ms ease;
} }
.dai-root .dai-composer[data-focused='true'] { .dai-root .dai-composer[data-focused='true'] {
border-color: var(--dai-accent); border-color: var(--dai-accent);
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06); box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06);
} }
.dai-root .dai-composer textarea { .dai-root .dai-composer textarea {
display: block; display: block;
width: 100%; width: 100%;
@@ -641,51 +593,13 @@
max-height: 108px; /* ~5 lines */ max-height: 108px; /* ~5 lines */
overflow-y: auto; overflow-y: auto;
} }
.dai-root .dai-composer textarea::placeholder { .dai-root .dai-composer textarea::placeholder {
color: var(--dai-text-muted); 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 { .dai-root .dai-hint {
font-size: 11px; font-size: 11px;
color: var(--dai-text-muted); color: var(--dai-text-muted);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Trigger (lives in the app TopNav) Trigger (lives in the app TopNav)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -693,40 +607,44 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 40px;
height: 32px; height: 40px;
padding: 0; padding: 0;
border-radius: 5px; border-radius: 5px;
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
color: #4f46e5;
cursor: pointer; cursor: pointer;
overflow: hidden;
transition: transition:
background-color 140ms ease, background-color 140ms ease,
border-color 140ms ease, border-color 140ms ease,
transform 140ms ease; transform 140ms ease;
} }
/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
.dai-trigger:hover { 30px inside the 40px button. */
background: rgba(99, 102, 241, 0.09); .dai-trigger-mark {
border-color: rgba(99, 102, 241, 0.16); 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 { .dai-trigger:active {
transform: scale(0.94); transform: scale(0.94);
} }
.dai-trigger:focus-visible { .dai-trigger:focus-visible {
outline: 2px solid var(--dai-accent); outline: 2px solid var(--dai-accent);
outline-offset: 2px; outline-offset: 2px;
} }
.dai-trigger[data-active='true'] { .dai-trigger[data-active='true'] {
color: #ffffff; background: rgba(0, 0, 0, 0.07);
background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to)); border-color: rgba(0, 0, 0, 0.14);
border-color: transparent;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Responsive Responsive
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -735,24 +653,17 @@
--dai-panel-width: 396px; --dai-panel-width: 396px;
} }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.dai-root { .dai-root {
--dai-inset: 0px; --dai-inset: 0px;
} }
.dai-panel { .dai-panel {
width: 100vw; width: 100vw;
max-width: 100vw; max-width: 100vw;
border-radius: 0; border-radius: 0;
border: none; border: none;
} }
.dai-msg-user {
max-width: 88%;
}
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Reduced motion — no slide, no pulse, no shimmer. Opacity only. Reduced motion — no slide, no pulse, no shimmer. Opacity only.
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -761,11 +672,9 @@
.dai-scrim { .dai-scrim {
transition: opacity 1ms linear; transition: opacity 1ms linear;
} }
.dai-panel { .dai-panel {
transform: none; transform: none;
} }
.dai-msg, .dai-msg,
.dai-live-dot, .dai-live-dot,
.dai-typing span, .dai-typing span,
@@ -777,7 +686,6 @@
transition: none !important; transition: none !important;
} }
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Write-action confirm card Write-action confirm card
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@@ -787,7 +695,6 @@
border-radius: 5px; border-radius: 5px;
background: var(--dai-surface-alt); background: var(--dai-surface-alt);
} }
.dai-root .dai-action-icon { .dai-root .dai-action-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -799,28 +706,22 @@
color: #4f46e5; color: #4f46e5;
background: rgba(99, 102, 241, 0.1); background: rgba(99, 102, 241, 0.1);
} }
.dai-root .dai-action-summary { .dai-root .dai-action-summary {
font-size: 13px; font-size: 13px;
line-height: 1.35; line-height: 1.35;
font-weight: 550; font-weight: 550;
color: var(--dai-text); color: var(--dai-text);
} }
.dai-root .dai-action-note { .dai-root .dai-action-note {
font-size: 12px; font-size: 12px;
color: var(--dai-text-secondary); color: var(--dai-text-secondary);
} }
.dai-root .dai-ok { .dai-root .dai-ok {
color: #047857; color: #047857;
} }
.dai-root .dai-err { .dai-root .dai-err {
color: #b91c1c; color: #b91c1c;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Overflow containment Overflow containment
-------------------------------------------------------------------------- --------------------------------------------------------------------------
@@ -838,7 +739,6 @@
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
} }
/* Two fields per row that genuinely share the width. `flex-wrap` is the /* 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 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 label, a narrower panel), the row drops to two lines instead of pushing the
@@ -850,12 +750,10 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
.dai-root .dai-form-row > * { .dai-root .dai-form-row > * {
flex: 1 1 140px; flex: 1 1 140px;
min-width: 0; min-width: 0;
} }
/* Astryx's TextInput sizes itself to content unless told otherwise. */ /* Astryx's TextInput sizes itself to content unless told otherwise. */
.dai-root .dai-form input, .dai-root .dai-form input,
.dai-root .dai-form-row input { .dai-root .dai-form-row input {
@@ -863,34 +761,62 @@
min-width: 0; min-width: 0;
box-sizing: border-box; box-sizing: border-box;
} }
.dai-root .dai-field-err { .dai-root .dai-field-err {
font-size: 11.5px; font-size: 11.5px;
line-height: 1.4; line-height: 1.4;
color: #b91c1c; color: #b91c1c;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Source footer + full lists Source footer + full lists
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
/* The footer is a baseline-aligned row, not a centred one: the timestamp and /* Timestamp left, Sources toggle hard right, both on ONE line. It was a
the Sources toggle are both small text and must sit on the same line. */ 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 { .dai-root .dai-msg-foot {
display: flex; display: flex;
align-items: baseline; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 10px;
width: 100%; width: 100%;
min-width: 0; 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 { .dai-root .dai-msg-foot .dai-msg-footer {
flex: 0 1 auto; flex: 1 1 auto;
min-width: 0; 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 /* 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. */ long list can never push the panel's height around. */
.dai-root .dai-list { .dai-root .dai-list {
@@ -901,7 +827,6 @@
background: var(--dai-surface-alt); background: var(--dai-surface-alt);
overflow: hidden; overflow: hidden;
} }
.dai-root .dai-list-head { .dai-root .dai-list-head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -915,14 +840,12 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--dai-text-muted); color: var(--dai-text-muted);
} }
.dai-root .dai-list-body { .dai-root .dai-list-body {
max-height: 260px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.dai-root .dai-list-item { .dai-root .dai-list-item {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -934,11 +857,9 @@
color: var(--dai-text); color: var(--dai-text);
border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05)); border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05));
} }
.dai-root .dai-list-item:first-child { .dai-root .dai-list-item:first-child {
border-top: none; border-top: none;
} }
.dai-root .dai-list-index { .dai-root .dai-list-index {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 18px; min-width: 18px;
@@ -946,7 +867,6 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--dai-text-muted); color: var(--dai-text-muted);
} }
/* The label must not be crushed to zero width by a long value — that is what /* 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 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 value takes the remaining space, wrapping onto its own full-width line when
@@ -956,7 +876,6 @@
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.dai-root .dai-list-meta { .dai-root .dai-list-meta {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -965,10 +884,36 @@
color: var(--dai-text-secondary); color: var(--dai-text-secondary);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* A value that wrapped to its own line reads better left-aligned under its /* A value that wrapped to its own line reads better left-aligned under its
label than pinned to the right edge. */ label than pinned to the right edge. */
.dai-root .dai-list-item > .dai-list-meta:only-child, .dai-root .dai-list-item > .dai-list-meta:only-child,
.dai-root .dai-list-meta[data-wrapped='true'] { .dai-root .dai-list-meta[data-wrapped='true'] {
text-align: left; 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;
}

View File

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

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

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { memo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { CopyOutlined } from '@ant-design/icons'; import { CopyOutlined } from '@ant-design/icons';
import { LuChevronDown, LuArrowRight } from 'react-icons/lu'; import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
@@ -7,11 +7,13 @@ import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Button } from '@astryxdesign/core/Button';
import { ChatToolCalls } from '@astryxdesign/core/Chat'; import { ChatToolCalls } from '@astryxdesign/core/Chat';
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts'; import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
import AICustomerForm from './AICustomerForm'; import AIFlowStep from './AIFlowStep';
import AIOrderForm from './AIOrderForm'; import AIRowsStep from './AIRowsStep';
import { failedRowsCsv, downloadCsv } from '../bulkFile';
// ==============================|| Doormile AI — a single turn ||============================== // // ==============================|| Doormile AI — a single turn ||============================== //
// //
@@ -32,7 +34,7 @@ const UserMessage = ({ text }) => (
UserMessage.propTypes = { text: PropTypes.string.isRequired }; 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 [showSources, setShowSources] = useState(false);
const sourceCount = message.sourceCalls?.length || 0; 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.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> </VStack>
)} )}
@@ -81,13 +88,15 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
<VStack gap={1} padding={0}> <VStack gap={1} padding={0}>
<HStack className="dai-msg-foot" gap={0} padding={0}> <HStack className="dai-msg-foot" gap={0} padding={0}>
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text> <Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
<button type="button" className="dai-link" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}> {/* The chevron's offset and rotation live in the stylesheet — as inline
{showSources ? 'Hide' : 'Sources'} ({sourceCount}) styles they couldn't be transitioned, and `verticalAlign` only
<LuChevronDown works while the button's content is inline, which is exactly the
size={11} thing that made it wrap. */}
style={{ marginLeft: 3, verticalAlign: '-1px', transform: showSources ? 'rotate(180deg)' : 'none' }} <button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
aria-hidden="true" <span>
/> {showSources ? 'Hide' : 'Sources'} ({sourceCount})
</span>
<LuChevronDown size={11} aria-hidden="true" />
</button> </button>
</HStack> </HStack>
{showSources && <ChatToolCalls calls={message.sourceCalls} />} {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 localStorage in a later session is marked 'stale' by the panel and
renders read-only, so a write can never fire from a resurrected renders read-only, so a write can never fire from a resurrected
message. */} message. */}
{message.form?.kind === 'createOrder' && ( {/* A conversational step that needs a dropdown rather than typing. */}
<AIOrderForm {message.liveStop && (
status={message.form.status} <VStack className="dai-action" gap={1} padding={0}>
error={message.form.error} <Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
onSubmit={(values) => onSubmitForm(message, values)} </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)} onCancel={() => onCancelAction(message)}
/> />
)} )}
{message.form?.kind === 'createCustomer' && ( {message.flowStep?.type === 'select' && (
<AICustomerForm <AIFlowStep
initial={message.form.initial} step={message.flowStep}
status={message.form.status} draft={message.flowDraft}
error={message.form.error} isBusy={message.flowStep.__answered}
onSubmit={(values) => onSubmitForm(message, values)} onChoose={(value, option) => onChooseStep(message, value, option)}
onCancel={() => onCancelAction(message)} 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 && ( {message.followUps?.length > 0 && (
<VStack gap={1} padding={0}> <VStack gap={1} padding={0}>
{message.followUps.map((q) => ( {message.followUps.map((q) => (
@@ -138,14 +197,24 @@ AssistantMessage.propTypes = {
onCopy: PropTypes.func.isRequired, onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired, onAsk: PropTypes.func.isRequired,
onSubmitForm: 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' ? ( message.sender === 'user' ? (
<UserMessage text={message.text} /> <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 = { AIMessage.propTypes = {
@@ -153,7 +222,14 @@ AIMessage.propTypes = {
onCopy: PropTypes.func.isRequired, onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired, onAsk: PropTypes.func.isRequired,
onSubmitForm: 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);

View File

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

View File

@@ -16,7 +16,11 @@ import { OpenToast } from 'components/third-party/OpenToast';
import { STATUS } from 'themes/dt/tokens'; import { STATUS } from 'themes/dt/tokens';
import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents'; import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents';
import { executeCreateCustomer, buildCustomerPayload } from '../actions'; 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 { getPageContext } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts'; import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome'; import AIWelcome from './AIWelcome';
@@ -53,7 +57,10 @@ const loadHistory = () => {
// last closed is retired on load. Otherwise a Create button could sit in // 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 // restored history for days and fire against data the operator can no
// longer see — the proposal must be regenerated against current state. // 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 { } catch {
return []; return [];
} }
@@ -78,10 +85,25 @@ const AIPanel = ({ isOpen, onClose }) => {
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [context, setContext] = useState({ lastIntentId: null, lastParams: null }); const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
const [isPinned, setIsPinned] = useState(true); 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 panelRef = useRef(null);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const lastQuestionRef = 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 ---- // ---- open / close transition ----
useEffect(() => { useEffect(() => {
@@ -126,19 +148,209 @@ const AIPanel = ({ isOpen, onClose }) => {
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [messages, isSending, isPinned]); }, [messages, isSending, isPinned]);
const handleScroll = () => { // A scroll event fires several times per frame, and this used to call
const el = scrollRef.current; // setIsPinned on every one of them — re-rendering the entire thread mid-scroll.
if (!el) return; // The measurement is now rAF-throttled and the state is written only when the
setIsPinned(el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX); // 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; const el = scrollRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
pinnedRef.current = true;
setIsPinned(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( const ask = useCallback(
async (text) => { async (text) => {
@@ -152,14 +364,111 @@ const AIPanel = ({ isOpen, onClose }) => {
setIsSending(true); setIsSending(true);
try { 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); const result = await answerQuestion(question, context);
if (result) { if (result) {
setContext({ lastIntentId: result.intentId, lastParams: result.params }); 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({ push({
form: result.form, form: result.form,
sender: 'assistant', sender: 'assistant',
text: result.headline, text: result.headline,
detail: result.detail, detail: result.detail,
// Documentation answers read as prose, not as a figure.
isDoc: result.intentId === 'docsAnswer',
metric: result.metric, metric: result.metric,
stats: result.stats, stats: result.stats,
sourceCalls: result.sourceCalls, sourceCalls: result.sourceCalls,
@@ -168,13 +477,18 @@ const AIPanel = ({ isOpen, onClose }) => {
} else { } else {
// Matched nothing. This is a coverage state, not a failure — say so // Matched nothing. This is a coverage state, not a failure — say so
// plainly rather than inventing an answer. // 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({ push({
sender: 'assistant', sender: 'assistant',
state: { state: {
tone: STATUS.muted, tone: STATUS.muted,
iconKey: 'info', iconKey: 'info',
title: "I can't answer that one yet", title: "I can't answer that one",
body: 'Try rephrasing around orders, riders, hubs, vehicles, batches, status, tenants or revenue — or pick one of the suggestions.' 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); 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'); const text = [m.text, m.detail].filter(Boolean).join('\n');
navigator.clipboard navigator.clipboard
.writeText(text) .writeText(text)
.then(() => OpenToast('Copied to clipboard', 'success', 1500)) .then(() => OpenToast('Copied to clipboard', 'success', 1500))
.catch(() => OpenToast('Could not copy', 'error', 1500)); .catch(() => OpenToast('Could not copy', 'error', 1500));
}; }, []);
const clearConversation = () => { const clearConversation = useCallback(() => {
setMessages([]); setMessages([]);
setContext({ lastIntentId: null, lastParams: null }); setContext({ lastIntentId: null, lastParams: null });
setFlow(null);
pinnedRef.current = true;
setIsPinned(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 // The single place a write leaves the assistant. Reached only by pressing
// Create on an open form. // Create on an open form.
const submitForm = async (message, values) => { const submitForm = useCallback(
if (message.form?.status !== 'open') return; async (message, values) => {
setFormStatus(message.id, { status: 'running', error: undefined, initial: values }); if (message.form?.status !== 'open') return;
setFormStatus(message.id, { status: 'running', error: undefined, initial: values });
// ---- order ---- // ---- conversational order: submit what the chat gathered -------------
if (message.form.kind === 'createOrder') { if (message.form.kind === 'confirmOrder') {
// The saved location the operator chose — its address/pincode/coords go const draft = message.form.draft;
// on the booking, because pickuplocationid 500s server-side. const payload = buildOrderPayload(draft, draft.__pickup);
const payload = buildOrderPayload(values, values.__pickup); const res = await executeCreateOrder(payload);
const res = await executeCreateOrder(payload); if (res.ok) {
if (res.ok) { setFormStatus(message.id, { status: 'done' });
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({ push({
sender: 'assistant', sender: 'assistant',
text: `Created order ${res.bookingno || res.id || ''}.`.trim(), text: `Created ${res.created} of ${rows.length} order${rows.length === 1 ? '' : 's'}.`,
list: { list: res.failures.length
title: 'Submitted', ? {
numbered: false, title: 'Not created',
items: Object.entries(payload) numbered: false,
.filter(([k]) => k !== 'parcels') items: res.failures.map((f) => ({ label: rows[f.index]?.customer_name || `Row ${f.index + 1}`, meta: f.reason }))
.map(([k, v]) => ({ label: k, meta: String(v) })) }
.concat([{ label: 'parcels', meta: `${payload.parcels.length} · ${payload.parcels[0].itemdescription}` }]) : undefined,
}, detail:
detail: 'Saved to /admin/expressbooking. It will appear on the Orders page under Pending until a rider is assigned.', 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 sourceCalls: res.sourceCalls
}); });
} else { setFlow(null);
setFormStatus(message.id, { status: 'failed', error: res.message }); return;
push({ }
sender: 'assistant',
state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message }, // ---- order ----
sourceCalls: res.sourceCalls 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 { // A dropdown selection. Same code path as a typed answer — the only
const payload = buildCustomerPayload(values); // difference is that the chosen OPTION comes along too, because steps that
const res = await executeCreateCustomer(payload); // pick a record (a location, a customer) fill several fields from it.
if (res.ok) { const chooseStep = useCallback(
setFormStatus(message.id, { status: 'done' }); async (message, value, option) => {
push({ if (!flow || flow.complete) return;
sender: 'assistant', // Mark the rendered dropdown as spent so it can't be submitted twice while
text: `Created ${[payload.firstname, payload.lastname].filter(Boolean).join(' ')}.`, // the next step is loading.
// Everything that was actually sent, so the operator can see the setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m)));
// whole record instead of a one-line summary. setIsSending(true);
list: { try {
title: 'Saved', const isBulk = flow.kind === 'createBulk';
numbered: false, const next = isBulk ? await answerBulkStep(flow, value, option) : await answerOrderStep(flow, value, option);
items: Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) })) setFlow(next.flow);
}, await renderFlowStep(next, isBulk ? finishBulk : finishOrder);
detail: `Customer ID ${res.id ?? '—'}.\nSaved to /admin/customers — it should now appear on the Customers page.`, } catch (err) {
sourceCalls: res.sourceCalls console.error('[Doormile AI] flow step failed', err);
}); push({ sender: 'assistant', text: 'Something went wrong on that step — say “cancel” and start again.' });
} else { } finally {
setFormStatus(message.id, { status: 'failed', error: res.message }); setIsSending(false);
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); [flow, push, renderFlowStep, finishBulk, finishOrder]
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.`
});
}
};
const cancelAction = (message) => { // Stops the long locate/price pass. The flag is a ref for the reason
if (message.form?.status !== 'open') return; // documented on it — as state it was captured at call time and never seen.
setFormStatus(message.id, { status: 'cancelled' }); 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; if (!isMounted) return null;
@@ -361,6 +814,8 @@ const AIPanel = ({ isOpen, onClose }) => {
onAsk={ask} onAsk={ask}
onSubmitForm={submitForm} onSubmitForm={submitForm}
onCancelAction={cancelAction} onCancelAction={cancelAction}
onChooseStep={chooseStep}
onStopLive={stopLive}
/> />
))} ))}
{isSending && ( {isSending && (

View File

@@ -1,20 +1,25 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { LuSparkles } from 'react-icons/lu';
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons'; import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import doormileMark from 'assets/images/doormile-mark.png';
// ==============================|| Doormile AI — shared primitives ||============================== // // ==============================|| Doormile AI — shared primitives ||============================== //
// //
// Small presentational pieces shared by the panel's states. Kept in one file // 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. // 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 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' }) => ( export const Spark = ({ size = 'sm' }) => (
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true"> <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> </HStack>
); );

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

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LuArrowRight } from 'react-icons/lu'; import { LuArrowRight } from 'react-icons/lu';
@@ -41,9 +40,10 @@ SuggestionCard.propTypes = {
}; };
const AIWelcome = ({ context, onAsk }) => { const AIWelcome = ({ context, onAsk }) => {
const [expanded, setExpanded] = useState(false); // Every suggestion for this page, always. `context.more` used to sit behind a
const more = context.more || []; // "View N more" / "Show fewer" toggle; hiding half the things the assistant
const shown = expanded ? [...context.suggestions, ...more] : context.suggestions; // can answer behind a click made it look narrower than it is.
const shown = [...context.suggestions, ...(context.more || [])];
return ( return (
<VStack className="dai-welcome" gap={3} padding={0}> <VStack className="dai-welcome" gap={3} padding={0}>
@@ -65,11 +65,6 @@ const AIWelcome = ({ context, onAsk }) => {
<SuggestionCard key={item.text} item={item} onAsk={onAsk} /> <SuggestionCard key={item.text} item={item} onAsk={onAsk} />
))} ))}
</VStack> </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>
</VStack> </VStack>
); );

View File

@@ -1,8 +1,8 @@
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { LuSparkles } from 'react-icons/lu';
import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Tooltip } from '@astryxdesign/core/Tooltip';
import doormileMark from 'assets/images/doormile-mark.png';
import AIPanel from './AIPanel'; import AIPanel from './AIPanel';
import '../DoormileAI.css'; import '../DoormileAI.css';
@@ -33,7 +33,7 @@ const DoormileAITrigger = () => {
aria-expanded={isOpen} aria-expanded={isOpen}
onClick={() => (isOpen ? close() : setIsOpen(true))} 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> </button>
</Tooltip> </Tooltip>
<AIPanel isOpen={isOpen} onClose={close} /> <AIPanel isOpen={isOpen} onClose={close} />

View File

@@ -10,7 +10,8 @@ import {
LuLayers, LuLayers,
LuTimerOff, LuTimerOff,
LuUserPlus, LuUserPlus,
LuPackagePlus LuPackagePlus,
LuListPlus
} from 'react-icons/lu'; } from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== // // ==============================|| Doormile AI — page context ||============================== //
@@ -34,6 +35,7 @@ const ORDERS = {
{ icon: LuPackagePlus, text: 'Create an order' } { icon: LuPackagePlus, text: 'Create an order' }
], ],
more: [ more: [
{ icon: LuListPlus, text: 'Create multiple orders' },
{ icon: LuBanknote, text: 'Total revenue today' }, { icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuCircleDot, text: 'How many pending orders today?' }, { icon: LuCircleDot, text: 'How many pending orders today?' },
{ icon: LuPackage, text: 'How many cancelled orders today?' }, { icon: LuPackage, text: 'How many cancelled orders today?' },
@@ -159,7 +161,43 @@ const ROUTES = [
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }] ['/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 = '') => { export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix)); const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
return match ? match[1] : DEFAULT_CONTEXT; return withEveryQuestion(match ? match[1] : DEFAULT_CONTEXT);
}; };

View File

@@ -1,4 +1,4 @@
import { createAdminCustomer } from 'pages/api/doormileApi'; import { createTenantCustomer } from 'pages/api/doormileApi';
// ==============================|| Doormile AI — write actions ||============================== // // ==============================|| Doormile AI — write actions ||============================== //
// //
@@ -102,35 +102,43 @@ export const buildCustomerPayload = (draft) =>
// //
// ---- Which endpoint, and why ----------------------------------------------- // ---- Which endpoint, and why -----------------------------------------------
// //
// Writes to POST /admin/customers — the same resource the Customers page // Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
// reads, so a created customer shows up there immediately. // by reading the docs:
// //
// Note for anyone reading express-console-api.md: that document lists // POST /admin/customers → 405 Method Not Allowed (confirmed live)
// /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.
// //
// The previous target was POST /admin/tenantcustomers, which succeeded but // 405 is the unambiguous answer: the route exists, and POST is not among its
// wrote to a different store with its own id sequence — a customer created // methods. express-console-api.md lists /admin/customers as GET + PATCH only,
// that way came back as id 2 while the Customers list ran to id 44, and was // and the server agrees. It was pointed there briefly on explicit instruction;
// invisible on that page. That is the bug this change fixes. // the live 405 settled it.
// //
// The payload already mirrors this resource's own GET shape (firstname, // The consequence, which the assistant states in its success message rather
// lastname, phone, email, address, doorno, landmark, suburb, city, state, // than leaving the operator to discover: a customer created here does NOT
// postcode, latitude, longitude), so nothing else had to move. // 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) => { export const executeCreateCustomer = async (payload) => {
const started = Date.now(); const started = Date.now();
const call = { const call = {
name: 'createAdminCustomer', name: 'createTenantCustomer',
target: 'POST /admin/customers', target: 'POST /admin/tenantcustomers',
stats: Object.keys(payload).join(', ') stats: Object.keys(payload).join(', ')
}; };
try { try {
const res = await createAdminCustomer(payload); const res = await createTenantCustomer(payload);
const duration = `${Date.now() - started}ms`; const duration = `${Date.now() - started}ms`;
// doormileApi mutations return the full envelope, so a `success: false` // 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, // from "the body was wrong". Report the status, the server's own message,
// and name the endpoint. // and name the endpoint.
const duration = `${Date.now() - started}ms`; const duration = `${Date.now() - started}ms`;
const status = err.response?.status; // doormileAxios rejects with the response BODY, not the axios error, so
const serverMessage = err.response?.data?.message || err.response?.data?.error; // `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; let message;
if (status === 404 || status === 405) { if (status === 405) {
// express-console-api.md documents /admin/customers as GET + PATCH only. // 405 is "the route exists but not this method" — a different fact from
// If the server agrees, say so plainly rather than implying the operator // 404, and worth stating precisely so nobody re-tries the same call.
// typed something wrong. message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
message = } else if (status === 404) {
`POST /admin/customers returned ${status} — that route does not exist on the server. ` + message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
'A customer there is created by a booking, or via POST /admin/tenantcustomers.'; } else if (status === 400 || status === 422) {
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
} else if (status) { } 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 { } else {
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`; message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
} }

View 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'
])
);

View 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);

View 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` }))
};
};

View 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) }));

View 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) });
};

View File

@@ -15,7 +15,6 @@ import {
getCompetitorBranches, getCompetitorBranches,
getCarrierPricing, getCarrierPricing,
getMilerSummary, getMilerSummary,
getBookingTrack,
getMilerActivity, getMilerActivity,
trackConsignment, trackConsignment,
getConsignmentLogs, getConsignmentLogs,
@@ -31,6 +30,8 @@ import { STATUS } from 'themes/dt/tokens';
// intent must have no route to a write. // intent must have no route to a write.
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions'; import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
import { CREATE_ORDER_TRIGGER } from './orderActions'; 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'; import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
// ==============================|| Doormile Bot — intent catalog ||============================== // // ==============================|| Doormile Bot — intent catalog ||============================== //
@@ -709,6 +710,14 @@ const nameAfterKeyword = (text, keyword) => {
// riderLookup just because it contains the word "rider". // 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; 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 // 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 // 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 // (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 MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [ 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 // Ordered FIRST, above createCustomer: "create an order" contains
// "order", which orderLookup / orderQuery / totalOrders all match on. // "order", which orderLookup / orderQuery / totalOrders all match on.
// Like createCustomer this NEVER mutates — it opens a form, and the panel // Like createCustomer this NEVER mutates — the panel intercepts this and
// submits only when the operator presses Create. // starts a CONVERSATION (orderFlow.js); the mutation fires only when the
// operator presses Create on the priced confirmation at the end.
id: 'createOrder', id: 'createOrder',
label: 'Create an order — e.g. "create an order"', label: 'Create an order — e.g. "create an order"',
match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null), match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null),
run: async () => ({ run: async () => ({
headline: 'Fill this in and I’ll create the order.', headline: 'Let’s build the order.',
form: { kind: 'createOrder', status: 'open' }, form: { kind: 'createOrder', status: 'open' },
sourceCalls: [] sourceCalls: []
}) })
@@ -785,11 +808,15 @@ const INTENTS = [
}, },
{ {
id: 'orderLookup', 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) => { match: (text) => {
if (!/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
const ref = orderIdFromWords(text); const ref = orderIdFromWords(text);
if (!ref) return null; 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 }; return { orderId: ref.id, strong: ref.strong };
}, },
run: async ({ orderId, 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 // Resolve the rider to a NAME (the booking only carries an id), and pull
// the tracking trail. Both are enrichment: either failing degrades that // the tracking trail. Both are enrichment: either failing degrades that
// one line rather than the whole answer. // 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), 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; const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null;
const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : 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 extraCalls = []; const extraCalls = [];
if (milers) { if (milers) {
@@ -848,21 +878,14 @@ const INTENTS = [
stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id' stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id'
}); });
} }
extraCalls.push( if (customers) {
steps extraCalls.push({
? { name: 'getAdminCustomers',
name: 'getBookingTrack', target: '/admin/customers',
target: `/admin/bookings/${found.bookingid}/track`, status: 'complete',
status: 'complete', stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id'
stats: `${steps.length} events` });
} }
: {
name: 'getBookingTrack',
target: `/admin/bookings/${found.bookingid}/track`,
status: 'error',
errorMessage: 'No tracking data returned'
}
);
const riderLine = found.assignedmileruserid const riderLine = found.assignedmileruserid
? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${ ? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${
@@ -870,19 +893,63 @@ const INTENTS = [
}` }`
: 'Not yet assigned to a rider.'; : '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 { return {
headline: `Order ${bookingLabel(found)} is ${status}.`, headline: `Order ${bookingLabel(found)} is ${status}.`,
detail: [ list: { title: 'The full record', numbered: false, items },
riderLine, // Lateness is the one thing worth saying before the rows are read.
found.pickupaddress ? `Pickup: ${found.pickupaddress}` : null, detail:
found.deliveryaddress ? `Drop: ${found.deliveryaddress}` : null, delayState(found, dayjs()) === 'late'
etaOf(found) ? `Promised by ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}` : null, ? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.`
steps && steps.length : undefined,
? `${steps.length} tracking event${steps.length === 1 ? '' : 's'}${lastStep?.status ? `, latest: ${lastStep.status}` : ''}`
: null
]
.filter(Boolean)
.join('\n'),
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls] sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls]
}; };
} }
@@ -1940,6 +2007,37 @@ const INTENTS = [
sourceCalls: [scanCall(scan, `${rows.length} matched`)] 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 = {}) { export async function answerQuestion(text, context = {}) {
const normalized = correctTypos(text); 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 // Tried BEFORE the single-intent pass: several intents match on a bare
// substring ("revenue" anywhere in the text) and their `run` never // substring ("revenue" anywhere in the text) and their `run` never
// returns null, so on a combined question like "orders and revenue // 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; 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; return null;
} }

View File

@@ -139,7 +139,14 @@ export const executeCreateOrder = async (payload) => {
}; };
} catch (err) { } catch (err) {
const duration = `${Date.now() - started}ms`; 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 }] }; return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
} }
}; };

View 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);

View 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';
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { MdPersonAddAlt1 } from 'react-icons/md'; import { MdPersonAddAlt1 } from 'react-icons/md';
@@ -11,7 +11,6 @@ import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Divider } from '@astryxdesign/core/Divider'; import { Divider } from '@astryxdesign/core/Divider';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -19,34 +18,32 @@ import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout'; import { PageShell } from 'components/nearle_components/PageLayout';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; 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 { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives'; import { AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand; const BRAND = DT.brand;
// Same field set as nearle_console_express's createCustomer.js (Location, // Writes to POST /admin/customers — the same resource the Customers page
// Choose Client, Name, Phone, Email, Door No, Address, Location/City/State/ // (customers.js) reads via getAdminCustomers(), so a customer created here
// Postcode, Landmark) — but POST /admin/tenantcustomers only persists // shows up there immediately. This used to call createTenantCustomer
// { firstname, lastname, phone, email }. There is no address/coordinate // (POST /admin/tenantcustomers), a different store with its own id
// storage anywhere on the new API for a customer record (confirmed against // sequence — a customer created that way never appeared on the Customers
// express-console-api.md — the old jupiter /customers/create body doesn't // list. See src/pages/nearle/assistant/CLAUDE.md ("Customer creation writes
// exist here at all). The address section below is collected and shown so // to /admin/customers") for the full history; the Doormile AI assistant's
// the form matches the reference 1:1, but it is NOT sent on submit — see the // executeCreateCustomer was fixed first, this page now matches it.
// note under that section. `tenantid` isn't in the documented POST body //
// either; it's included speculatively for staff logins (every other create // This resource carries no tenantid (confirmed against a real
// endpoint on this API takes tenantid explicitly) and may be silently // GET /admin/customers response), so there's no tenant picker here — unlike
// ignored server-side — unconfirmed, this endpoint isn't in the "confirmed // Tenants/Riders/etc, a customer isn't scoped to a client at creation time.
// end-to-end" list. // 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 Createcustomer = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const loggedInTenantId = localStorage.getItem('tenantid');
const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0';
const [appId, setAppId] = useState(0); const [appId, setAppId] = useState(0);
const [locaName, setLocoName] = useState('Select Location'); const [locaName, setLocoName] = useState('Select Location');
const [tid, setTid] = useState(isStaffLogin ? '' : loggedInTenantId);
const [firstname, setFirstname] = useState(''); const [firstname, setFirstname] = useState('');
const [lastname, setLastname] = useState(''); const [lastname, setLastname] = useState('');
@@ -60,13 +57,6 @@ const Createcustomer = () => {
const [appLocaLat, setAppLocaLat] = useState(); const [appLocaLat, setAppLocaLat] = useState();
const [appLocaLng, setAppLocaLng] = 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 // LocationAutocomplete only reports back applocationid/locationname — it
// caches the full hub list (with lat/lng) in localStorage under a // caches the full hub list (with lat/lng) in localStorage under a
// tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js) // tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js)
@@ -123,8 +113,13 @@ const Createcustomer = () => {
const opentoast = (message) => const opentoast = (message) =>
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 }); 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({ const createCustomerMutation = useMutation({
mutationFn: (payload) => createTenantCustomer(payload), mutationFn: (payload) => createAdminCustomer(payload),
onSuccess: (res) => { onSuccess: (res) => {
if (res.success) { if (res.success) {
enqueueSnackbar('Customer created successfully', { enqueueSnackbar('Customer created successfully', {
@@ -141,20 +136,28 @@ const Createcustomer = () => {
}); });
const createprofile = () => { const createprofile = () => {
if (isStaffLogin && !tid) { if (!firstname) {
opentoast('Select Tenant');
} else if (!firstname) {
opentoast('Enter Name'); opentoast('Enter Name');
} else if (!phone || phone.length !== 10) { } else if (!phone || phone.length !== 10) {
opentoast('Enter a valid 10-digit Mobile Number'); opentoast('Enter a valid 10-digit Mobile Number');
} else { } else {
createCustomerMutation.mutate({ createCustomerMutation.mutate(
firstname, clean({
lastname, firstname,
phone, lastname,
email, phone,
...(isStaffLogin && tid ? { tenantid: +tid } : {}) 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> </Text>
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} /> <LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
</VStack> </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="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional /> <TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
<TextInput <TextInput
@@ -210,11 +203,6 @@ const Createcustomer = () => {
<Divider /> <Divider />
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved
on submit.
</Text>
<AddressAutocomplete <AddressAutocomplete
id="customer-address" id="customer-address"
label="Address" label="Address"

View File

@@ -56,17 +56,39 @@ import StatCard from 'components/nearle_components/StatCard';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; 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 { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT } from 'themes/dt/tokens'; import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives'; import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
// ============================================================================ // ============================================================================
// GET /admin/customers confirmed live: { appcustomerid, createdat, email, // This page reads GET /admin/tenantcustomers, NOT /admin/customers.
// name, phone, totalbookings } — no city/address/firstname field exists at //
// all. PATCH /admin/customers/:id is the only mutation the API exposes; // Why: /admin/customers cannot be written to. `POST /admin/customers` returns
// there is no create/delete here by design ("Tenant-scoped through their // **405 Method Not Allowed** (confirmed live) — the route exists and POST is
// bookings" — a customer only exists once they've ordered through a tenant). // 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 // The edit dialog replicates nearle_console_express's customer edit dialog
// field-for-field (Name, Contact, Address, Location, City, State, Postcode, // 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 BRAND = DT.brand;
const custId = (row) => row.appcustomerid; const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.customerid ?? row.id;
const custName = (row) => row.name || '—'; const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
const custPhone = (row) => row.phone || '—'; const custPhone = (row) => row.phone || row.contactno || '—';
const custEmail = (row) => row.email || '—'; const custEmail = (row) => row.email || '—';
// Tenant customers carry no booking count; 0 is honest rather than blank.
const custBookings = (row) => row.totalbookings ?? 0; const custBookings = (row) => row.totalbookings ?? 0;
// TanStack Table v9 registers features explicitly — only sorting/resizing/ // TanStack Table v9 registers features explicitly — only sorting/resizing/
@@ -122,7 +145,7 @@ const Customers = () => {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({}); 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(() => { const rows = useMemo(() => {
if (!debouncedSearch) return customers; if (!debouncedSearch) return customers;
@@ -144,11 +167,24 @@ const Customers = () => {
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 }); enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const saveMutation = useMutation({ 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) => { onSuccess: (res) => {
if (res.success) { if (res.success) {
opentoast('Customer updated', 'success'); opentoast('Customer updated', 'success');
queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); queryClient.invalidateQueries({ queryKey: ['tenant-customers'] });
setEditRow(null); setEditRow(null);
} else { } else {
opentoast(res.message || 'Failed to update customer'); opentoast(res.message || 'Failed to update customer');
@@ -159,10 +195,11 @@ const Customers = () => {
const openEdit = (row) => { const openEdit = (row) => {
setEditRow(row); setEditRow(row);
// Seeded through the accessors so the dialog works on either record shape.
setForm({ setForm({
name: row.name || '', name: custName(row) === '—' ? '' : custName(row),
phone: row.phone || '', phone: custPhone(row) === '—' ? '' : custPhone(row),
email: row.email || '' email: custEmail(row) === '—' ? '' : custEmail(row)
}); });
setAddressInput(row.address || ''); setAddressInput(row.address || '');
setPickAddress({ setPickAddress({

View File

@@ -7,6 +7,7 @@ dayjs.extend(utc);
import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi'; import { notifyMiler, getConsignmentLogs } from 'pages/api/doormileApi';
import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared'; import { kalmanSmoothGps, polylineLengthKm } from '../dispatch/dispatchShared';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp'; import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { BATCHES as SHARED_BATCHES, batchRangeLabel } from 'utils/batchBucket';
import { import {
MdOutlineDateRange, MdOutlineDateRange,
MdDirectionsBike, MdDirectionsBike,
@@ -111,6 +112,10 @@ const BRAND = DT.brand;
// the canonical status registry (themes/dt/status.js) key to the // the canonical status registry (themes/dt/status.js) key to the
// `currentStatus` key the queries use AND to the `batchCounts` key (so the // `currentStatus` key the queries use AND to the `batchCounts` key (so the
// chip count for the tab is one lookup). // 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 = [ const STATUS_TABS = [
{ status: 'pending', countKey: 'uncoveredLength' }, { status: 'pending', countKey: 'uncoveredLength' },
{ status: 'accepted', countKey: 'assignedLength' }, { 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 // 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* // [startHour, endHour) — a delivery at exactly endHour falls into the *next*
// batch (or none, if the gap isn't covered). // 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 = [ const BATCH_OPTIONS = [
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' }, { 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 }, ...SHARED_BATCHES.map((b) => ({
{ id: b.id,
id: 'afternoon', label: b.label,
label: 'Afternoon Batch', range: batchRangeLabel(b),
range: '9 AM to 12:30 PM', startHour: b.startHour,
color: '#f59e0b', endHour: b.endHour,
iconKey: 'afternoon', ...BATCH_PRESENTATION[b.id]
startHour: 9, }))
endHour: 12.5
},
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
]; ];
// Auto-pick the batch matching the operator's LOCAL wall-clock hour so the // Auto-pick the batch matching the operator's LOCAL wall-clock hour so the
@@ -571,7 +583,14 @@ const Deliveries = () => {
isLoading: countSourceIsLoading, isLoading: countSourceIsLoading,
refetch: countSourceRefetch refetch: countSourceRefetch
} = useInfiniteQuery({ } = 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, queryFn: fetchDeliveries,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
}); });
@@ -618,7 +637,9 @@ const Deliveries = () => {
return countSourceRows.filter((r) => { return countSourceRows.filter((r) => {
if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false; if (selectedBatch !== 'all' && getRowBatchId(r) !== selectedBatch) return false;
const s = String(r.orderstatus || '').toLowerCase(); 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) { if (q) {
const hay = [ const hay = [
r.deliverycustomer, r.deliverycustomer,
@@ -817,8 +838,8 @@ const Deliveries = () => {
{ {
id: 'orderLocation', id: 'orderLocation',
accessorFn: (row) => row.orderid, accessorFn: (row) => row.orderid,
size: 220, size: 240,
minSize: 160, minSize: 180,
enableSorting: false, enableSorting: false,
meta: { label: 'Order / Location' }, meta: { label: 'Order / Location' },
header: () => ( header: () => (
@@ -826,46 +847,43 @@ const Deliveries = () => {
Order / Location Order / Location
</Text> </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 }) => { cell: ({ row }) => {
const r = row.original; 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 ( return (
<VStack gap={0} padding={0}> <VStack gap={0} padding={0} minWidth={0}>
<Tooltip content="Location Name-Suburb"> <Tooltip content="Location Name-Suburb">
<Text maxLines={1}>{r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname}</Text> <Text maxLines={1}>{r.locationsuburb ? `${r.locationname}-(${r.locationsuburb})` : r.locationname}</Text>
</Tooltip> </Tooltip>
<HStack gap={3}>
<VStack gap={0} padding={0}> <Tooltip content="Order Id">
<Tooltip content="Order Id"> <Text maxLines={1}>{r.orderid}</Text>
<Text maxLines={1}>{r.orderid}</Text> </Tooltip>
</Tooltip> <Text type="supporting" color="secondary">
{/* express-console-api.md: Doormile timestamps are IST Ordered {ordered.format('DD/MM/YYYY · hh:mm A')}
wall-clock with no timezone marker — bare dayjs() was </Text>
still being used here despite this comment saying
otherwise (the fix was never actually applied below, <Tooltip content="Delivery Id">
only claimed) — a Z-suffixed response reinterprets as <Text maxLines={1}>{r.deliveryid}</Text>
local time and shifts 5:30, showing the wrong time </Tooltip>
(and sometimes the wrong day). parseDoormileTimestamp <Text type="supporting" color="secondary">
strips that, matching getRowBatchId above. */} Delivery {delivered.format('DD/MM/YYYY · hh:mm A')}
<Tooltip content="Ordered date"> </Text>
<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>
</VStack> </VStack>
); );
} }
@@ -1140,6 +1158,16 @@ const Deliveries = () => {
const colCount = table.getVisibleLeafColumns().length; 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 // Counts per status, scoped to the selected batch. Keys mirror the legacy
// *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical // *Length keys returned by fetchCountAPI so the JSX swap-in is mechanical
// (countData?.uncoveredLength → batchCounts.uncoveredLength). // (countData?.uncoveredLength → batchCounts.uncoveredLength).
@@ -1147,6 +1175,7 @@ const Deliveries = () => {
const c = { const c = {
uncoveredLength: 0, uncoveredLength: 0,
assignedLength: 0, assignedLength: 0,
otherLength: 0,
arrivedLength: 0, arrivedLength: 0,
pickedLength: 0, pickedLength: 0,
activeLength: 0, activeLength: 0,
@@ -1184,7 +1213,12 @@ const Deliveries = () => {
case 'canceled': case 'canceled':
c.cancelLength += 1; c.cancelLength += 1;
break; 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: default:
c.otherLength += 1;
break; break;
} }
}); });
@@ -1540,7 +1574,15 @@ const Deliveries = () => {
const idx = STATUS_TABS.findIndex((t) => t.status === status); const idx = STATUS_TABS.findIndex((t) => t.status === status);
if (idx !== -1) handleChangetab(idx); 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" size="sm"
hasDivider={false} hasDivider={false}
/> />
@@ -1781,7 +1823,13 @@ const Deliveries = () => {
key={header.id} key={header.id}
scope="col" scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined} 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 <HStack
gap={1} gap={1}
@@ -2113,7 +2161,13 @@ const Deliveries = () => {
/> />
<Selector <Selector
label="Status" 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, value: s,
label: getStatusMeta(s).label label: getStatusMeta(s).label
}))} }))}

View File

@@ -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. 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 ```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: 'morning', startHour: 0, endHour: 9 }, // 12 AM – 9 AM
{ id: 'afternoon', startHour: 9, endHour: 12.5 }, // 9 AM – 12:30 PM { id: 'afternoon', startHour: 9, endHour: 16 }, // 9 AM – 4 PM
{ id: 'evening', startHour: 16, endHour: 19 } // 4 PM – 7 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`) ### 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. 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. 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:** **Three fields have been tried here. Two were wrong:**

File diff suppressed because it is too large Load Diff

View File

@@ -328,3 +328,24 @@
animation: none !important; 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);
}

View File

@@ -113,6 +113,11 @@ const sortLocations = (locations) =>
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others']; const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs']; 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 Createorder1 = () => {
const [inputValue2, setInputValue2] = useState(''); const [inputValue2, setInputValue2] = useState('');
const [inputValue3, setInputValue3] = useState(''); const [inputValue3, setInputValue3] = useState('');
@@ -123,7 +128,17 @@ const Createorder1 = () => {
// reason a persistent, visible message instead. // reason a persistent, visible message instead.
const [locationsStatus, setLocationsStatus] = useState('loading'); const [locationsStatus, setLocationsStatus] = useState('loading');
const [collectionamt, setCollectionamt] = useState(0); 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 navigate = useNavigate();
const [open4, setOpen4] = useState(false); const [open4, setOpen4] = useState(false);
@@ -315,7 +330,7 @@ const Createorder1 = () => {
deliverycity: dropCust.city || '', deliverycity: dropCust.city || '',
deliverylatitude: Number(dropCust.latitude) || 0, deliverylatitude: Number(dropCust.latitude) || 0,
deliverylongitude: Number(dropCust.longitude) || 0, deliverylongitude: Number(dropCust.longitude) || 0,
service_option: 'Normal', service_option: serviceOption,
finalprice: +totalCharge.toFixed(2) || 0, finalprice: +totalCharge.toFixed(2) || 0,
notes: otherinstructions || '', notes: otherinstructions || '',
// The parcel object has no numeric quantity field (confirmed against // 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 // This previously always sent exactly one parcel regardless of what the
// operator typed into the Quantity field, so it silently got dropped — // operator typed into the Quantity field, so it silently got dropped —
// matching "I entered 5, deliveries page shows 1". // matching "I entered 5, deliveries page shows 1".
parcels: Array.from({ length: Math.max(1, Number(quantity) || 1) }, () => ({ // One entry per package, expanded by its count. Falls back to the legacy
itemcategory: category || 'General', // single-category behaviour if the operator never touched the package
itemdescription: 'Order', // section, so an existing habit still works.
declaredvalue: +totalCharge.toFixed(2) || 0 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) { if (!arr.tenantid) {
opentoast('Pick a tenant before creating an order', 'warning', 2000); opentoast('Pick a tenant before creating an order', 'warning', 2000);
@@ -1172,22 +1204,131 @@ const Createorder1 = () => {
</Card> </Card>
</Grid> </Grid>
{/* ============================== || Cash Collect & Quantity || ============================== */} {/* ============================== || Package details || ============================== */}
<Card padding={0} elevation="none" className="co-card"> <Card padding={0} elevation="none" className="co-card">
<SectionHeader <SectionHeader
color="#6366f1" color="#6366f1"
icon={<MdInventory2 size={16} />} icon={<MdInventory2 size={16} />}
title="Collection & Quantity" title="Package Details"
subtitle="Cash to collect on delivery, total items" 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}> <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}> <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="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> </Grid>
</VStack> </VStack>
</Card> </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 || ================================================= */} {/* ================================================= || Notes || ================================================= */}
<Card padding={0} elevation="none" className="co-card"> <Card padding={0} elevation="none" className="co-card">
<SectionHeader <SectionHeader

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from 'react';
import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi'; import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete'; import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import Papa from 'papaparse'; import Papa from 'papaparse';
import { SHEET_HEADER_MAP, requiredSheetColumns, normalizeHeader, cleanReceiverName } from 'utils/bulkOrderColumns';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -606,30 +607,11 @@ const MultipleOrders = () => {
}; };
// ============================== CSV / XLSX upload ============================== // ============================== CSV / XLSX upload ==============================
const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name); // The map, the header normaliser and the name cleaner now live in
const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, ''); // utils/bulkOrderColumns.js so Doormile AI's file upload reads the exact same
// sheet this page does. Behaviour here is unchanged — the definitions moved,
const headerMap = { // they were not rewritten.
'pickupdate(yyyy-mmm-dd)': 'date', const headerMap = SHEET_HEADER_MAP;
'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'
};
const handleFileDirectUpload = (event) => { const handleFileDirectUpload = (event) => {
try { try {
@@ -660,7 +642,7 @@ const MultipleOrders = () => {
} }
return newRow; return newRow;
}); });
const requiredCols = Object.keys(headerMap).filter((k) => k.trim().endsWith('*')); const requiredCols = requiredSheetColumns();
const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol))); const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol)));
if (missingRequired.length > 0) { if (missingRequired.length > 0) {
opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000); opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000);

View File

@@ -63,9 +63,8 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
import StatusTabs from 'components/nearle_components/StatusTabs'; import StatusTabs from 'components/nearle_components/StatusTabs';
import TableLoader from 'components/nearle_components/TableLoader'; import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; 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 { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api'; import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
@@ -931,25 +930,6 @@ const Orders = () => {
<PageShell fill> <PageShell fill>
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live /> <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 <DataCard
fill fill
tabs={ tabs={

View File

@@ -58,9 +58,18 @@ export const STATUS_META = {
// "is this a known alias?" rather than silently falling back. // "is this a known alias?" rather than silently falling back.
// //
// The booking enums come from `GET /admin/bookings` (confirmed live — see // The booking enums come from `GET /admin/bookings` (confirmed live — see
// orders.js). `converted_to_consignment` is deliberately `accepted` and not // orders.js). `converted_to_consignment` stays `accepted` HERE and only here:
// `picked`: it fires when the rider marks pickup COMPLETE, but the operator // this registry renders the badge on the Orders page, whose operator workflow
// workflow on the Orders page treats everything before hand-off as assigned. // 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 = { export const STATUS_ALIASES = {
pending_pickup: 'pending', pending_pickup: 'pending',
pending_assignment: 'pending', pending_assignment: 'pending',

View File

@@ -9,13 +9,25 @@ import { parseDoormileTimestamp } from './doormileTimestamp';
// to import this (dispatch/CLAUDE.md is explicit that folder isn't touched // to import this (dispatch/CLAUDE.md is explicit that folder isn't touched
// casually); this is purely additive. // casually); this is purely additive.
// //
// Half-open [startHour, endHour) ranges in LOCAL time. Gaps (8–9am, // Half-open [startHour, endHour) ranges in LOCAL time, and they COVER THE
// 12:30pm–4pm, after 7pm) are intentional — a row in a gap belongs to no // WHOLE DAY. That is the point.
// batch, not the nearest one. //
// 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 = [ export const BATCHES = [
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 8 }, { id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 9 },
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 12.5 }, { id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 16 },
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 19 } { id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 24 }
]; ];
export const getBatchForHour = (h) => { export const getBatchForHour = (h) => {
@@ -25,6 +37,18 @@ export const getBatchForHour = (h) => {
return null; 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` // The one field batch bucketing uses across the app: a booking's `orderdate`
// (== createdat — see fetchDeliveries in api.js). `assigntime` and // (== createdat — see fetchDeliveries in api.js). `assigntime` and
// `expecteddeliverytime` were both tried and rejected — see dispatch/ // `expecteddeliverytime` were both tried and rejected — see dispatch/

View 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 };
};

View File

@@ -31,7 +31,21 @@ doormileAxios.interceptors.response.use(
localStorage.clear(); localStorage.clear();
window.location.replace('/login'); 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 }));
} }
); );