Compare commits
10 Commits
5284fc75bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a00e7a2a70 | |||
| 0f8510a3ea | |||
| 414ba6dbf2 | |||
| 3b239b949a | |||
| 505d50cc0a | |||
| 1bf89adb6a | |||
| ce32465a6f | |||
| e1cc874218 | |||
| 37ca2352e2 | |||
| bdb21766f2 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -104,3 +104,8 @@ dist
|
||||
|
||||
# wincompare file
|
||||
*.bak
|
||||
|
||||
# Local AI sidecar (dev only)
|
||||
.chroma/
|
||||
.cache/
|
||||
services/ai/node_modules/
|
||||
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal 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
|
||||
BIN
public/drop-icon.png
Normal file
BIN
public/drop-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
BIN
public/pickup-icon.png
Normal file
BIN
public/pickup-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 810 KiB |
59
services/ai/README.md
Normal file
59
services/ai/README.md
Normal 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.
|
||||
77
services/ai/collections.js
Normal file
77
services/ai/collections.js
Normal 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
36
services/ai/confidence.js
Normal 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
66
services/ai/embed.js
Normal 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
77
services/ai/eval-set.json
Normal 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
79
services/ai/eval.js
Normal 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
97
services/ai/index.js
Normal 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
18
services/ai/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
271
services/ai/seed/phrasings.json
Normal file
271
services/ai/seed/phrasings.json
Normal 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
148
services/ai/seed/run.js
Normal 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);
|
||||
});
|
||||
@@ -29,6 +29,9 @@ const DateFilterDialog = ({ open, onClose, onSelect }) => {
|
||||
open={open}
|
||||
toggle={onClose}
|
||||
id="daterange1"
|
||||
// Reflects the pick already made in this dialog, so reopening it or
|
||||
// adjusting one end does not blank the other.
|
||||
value={selectedRange || undefined}
|
||||
onChange={handleDateChange}
|
||||
definedRanges={[
|
||||
{ label: 'Today', startDate: new Date(), endDate: new Date() },
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
// ===========================|| HOVER SOCIAL CARD ||=========================== //
|
||||
// Solid-colour metric tile used on the order-preview summary row. `color` is a
|
||||
// caller-supplied background hex, so it stays an inline style — Astryx's Card
|
||||
// `variant` palette can't express an arbitrary colour.
|
||||
|
||||
const HoverSocialCard = ({ primary, secondary, percentage, color, style }) => (
|
||||
<Card padding={4} elevation="none" height="100%" style={{ background: color, color: '#fff', border: 'none', ...style }}>
|
||||
<HStack justify="between" vAlign="start" gap={2}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Heading level={2} color="inherit" maxLines={1}>
|
||||
{secondary}
|
||||
</Heading>
|
||||
<Text color="inherit" weight="medium">
|
||||
{primary}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Text color="inherit" size="2xl" display="block">
|
||||
{typeof percentage === 'number' ? `${percentage} %` : percentage}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
HoverSocialCard.propTypes = {
|
||||
primary: PropTypes.node,
|
||||
secondary: PropTypes.node,
|
||||
percentage: PropTypes.node,
|
||||
color: PropTypes.string,
|
||||
style: PropTypes.object
|
||||
};
|
||||
|
||||
export default HoverSocialCard;
|
||||
@@ -1,5 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
@@ -19,18 +19,43 @@ import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
// Astryx note: DateInput speaks ISO `YYYY-MM-DD` strings, where the MUI
|
||||
// DatePicker spoke dayjs objects. The conversion happens here so the
|
||||
// `onChange` contract (native Date objects) is unchanged for callers.
|
||||
export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
|
||||
const [customStart, setCustomStart] = useState(undefined);
|
||||
const [customEnd, setCustomEnd] = useState(undefined);
|
||||
const toISO = (d) => (d ? dayjs(d).format('YYYY-MM-DD') : undefined);
|
||||
|
||||
export const DateRangePicker = ({ onChange, definedRanges = [], value }) => {
|
||||
// Seeded from the range the caller is ALREADY filtering by. Without this the
|
||||
// two fields opened blank on a page that was actively filtered — both showing
|
||||
// "Select a date" while the table behind them was narrowed to a window the
|
||||
// operator could no longer see. Confirmed on the Deliveries page: the query
|
||||
// held a range, the inputs held "".
|
||||
const [customStart, setCustomStart] = useState(toISO(value?.startDate));
|
||||
const [customEnd, setCustomEnd] = useState(toISO(value?.endDate));
|
||||
|
||||
// Re-seeded when the caller's range changes underneath us — a preset button
|
||||
// here, or a filter reset on the page — so the fields never disagree with
|
||||
// what is actually being filtered.
|
||||
useEffect(() => {
|
||||
setCustomStart(toISO(value?.startDate));
|
||||
setCustomEnd(toISO(value?.endDate));
|
||||
}, [value?.startDate, value?.endDate]);
|
||||
|
||||
const pickPreset = (range) => {
|
||||
setCustomStart(toISO(range.startDate));
|
||||
setCustomEnd(toISO(range.endDate));
|
||||
onChange({ startDate: range.startDate, endDate: range.endDate, label: range.label });
|
||||
};
|
||||
|
||||
const pickCustom = (nextStart, nextEnd) => {
|
||||
if (nextStart && nextEnd) {
|
||||
onChange({ startDate: dayjs(nextStart).toDate(), endDate: dayjs(nextEnd).toDate(), label: undefined });
|
||||
}
|
||||
if (!nextStart || !nextEnd) return;
|
||||
|
||||
// Order enforced, not assumed. Only the END field carried a `min`, so a
|
||||
// start could be set AFTER an end already chosen — and because this fires
|
||||
// as soon as both are present, that emitted an inverted range that filters
|
||||
// to nothing. Swapping is what the operator meant either way; refusing
|
||||
// would just leave the picker silently doing nothing.
|
||||
const a = dayjs(nextStart);
|
||||
const b = dayjs(nextEnd);
|
||||
const [from, to] = a.isAfter(b) ? [b, a] : [a, b];
|
||||
onChange({ startDate: from.toDate(), endDate: to.toDate(), label: undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -51,6 +76,7 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
|
||||
<DateInput
|
||||
label="Start date"
|
||||
value={customStart}
|
||||
max={customEnd}
|
||||
hasClear
|
||||
onChange={(v) => {
|
||||
setCustomStart(v);
|
||||
@@ -75,7 +101,13 @@ export const DateRangePicker = ({ onChange, definedRanges = [] }) => {
|
||||
|
||||
DateRangePicker.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
definedRanges: PropTypes.array
|
||||
definedRanges: PropTypes.array,
|
||||
// The range currently in force, so the fields can show it. Accepts whatever
|
||||
// dayjs parses — the callers hold ISO strings, the presets hold Dates.
|
||||
value: PropTypes.shape({
|
||||
startDate: PropTypes.any,
|
||||
endDate: PropTypes.any
|
||||
})
|
||||
};
|
||||
|
||||
export default DateRangePicker;
|
||||
|
||||
@@ -6,40 +6,83 @@ import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { StatusDot } from '@astryxdesign/core/StatusDot';
|
||||
|
||||
// ==============================|| PAGE HEADER (Doormile-style clean title) ||============================== //
|
||||
// A plain, un-boxed page title + optional live subtitle + an action slot
|
||||
// (zone selector, primary button, etc.). Replaces the heavy boxed/gradient
|
||||
// header so every operator page opens with a calm, corporate heading.
|
||||
//
|
||||
// Astryx: Heading level={1} resolves to --text-heading-1-size (1.5rem), the
|
||||
// same size the old MUI `variant="h3"` rendered — the visual weight of every
|
||||
// page title is unchanged by the migration. The old responsive
|
||||
// `direction={{ xs: 'column', sm: 'row' }}` is now wrap="wrap" on a single
|
||||
// HStack: the action slot drops to its own line once it can't fit, which is
|
||||
// the behaviour that breakpoint was approximating.
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
export default function PageHeader({ title, subtitle, live = false, action }) {
|
||||
// ==============================|| PAGE HEADER ||============================== //
|
||||
//
|
||||
// A plain, un-boxed page title + optional live indicator + an action slot
|
||||
// (zone selector, primary button, etc.), so every operator page opens with a
|
||||
// calm, corporate heading.
|
||||
//
|
||||
// The STRUCTURE follows KROW's page frame; the colours and the type are the
|
||||
// console's own and are unchanged — `Heading level={1}` and `Text
|
||||
// type="supporting"` resolve to exactly the same tokens they did before.
|
||||
//
|
||||
// Title · meta · ● Live [ actions ]
|
||||
// subtitle
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two things the KROW work changed:
|
||||
//
|
||||
// - The bottom hairline. KROW closes every page header with a rule, and it
|
||||
// is what makes the header read as a REGION of the page rather than as
|
||||
// the first item in the content stack — without it, a page header and a
|
||||
// section heading look identical and the page has no top.
|
||||
//
|
||||
// - `live` moved from beside the subtitle up onto the title row. It was
|
||||
// putting the page's only motion cue on its quietest line; a live
|
||||
// indicator belongs where people look for it. It is still the same
|
||||
// pulsing `StatusDot variant="success"` — nothing about its colour moved.
|
||||
//
|
||||
// `meta` is new and optional: a real count that belongs beside the name
|
||||
// ("1,248 orders"), with tabular figures so it doesn't jitter as it updates.
|
||||
//
|
||||
// `action` keeps its name and still wraps to its own line once it can't fit,
|
||||
// which is the behaviour the old responsive `direction={{ xs: 'column', sm:
|
||||
// 'row' }}` was approximating.
|
||||
|
||||
export default function PageHeader({ title, subtitle, meta, live = false, action }) {
|
||||
return (
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={2}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Heading level={1} maxLines={1}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subtitle && (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<VStack
|
||||
gap={0}
|
||||
padding={0}
|
||||
style={{
|
||||
paddingBottom: 16,
|
||||
// The existing hairline at 50% alpha — a page header's rule should sit
|
||||
// under the table borders it introduces, not match them.
|
||||
borderBottom: `1px solid ${DT.borderSubtle}80`
|
||||
}}
|
||||
>
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
|
||||
{/* minWidth in `style` — VStack has no such prop (see StatCard). */}
|
||||
<VStack gap={0.5} padding={0} style={{ minWidth: 0 }}>
|
||||
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||
<Heading level={1} maxLines={1}>
|
||||
{title}
|
||||
</Heading>
|
||||
{meta != null && (
|
||||
<Text type="supporting" hasTabularNumbers>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
{live && <StatusDot variant="success" label="Live" isPulsing />}
|
||||
<Text type="supporting">{subtitle}</Text>
|
||||
</HStack>
|
||||
{subtitle && <Text type="supporting">{subtitle}</Text>}
|
||||
</VStack>
|
||||
{action && (
|
||||
<HStack gap={1.5} vAlign="center" wrap="wrap" style={{ flexShrink: 0 }}>
|
||||
{action}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
{action && <HStack gap={1.5}>{action}</HStack>}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeader.propTypes = {
|
||||
title: PropTypes.node,
|
||||
subtitle: PropTypes.node,
|
||||
meta: PropTypes.node,
|
||||
live: PropTypes.bool,
|
||||
action: PropTypes.node
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
// ==============================|| PAGE LAYOUT PRIMITIVES ||============================== //
|
||||
//
|
||||
// The operator-page design language, in one place.
|
||||
@@ -56,7 +58,11 @@ import { Divider } from '@astryxdesign/core/Divider';
|
||||
// ---------------------------------------------------------------------------
|
||||
const FILL_CLASS = 'dt-page-fill';
|
||||
|
||||
export function PageShell({ children, gap = 6, fill = false }) {
|
||||
// `className` is forwarded so a page can scope its own stylesheet to the
|
||||
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
|
||||
// silently, which made every page-scoped rule fail to match with no error —
|
||||
// the styles simply never applied.
|
||||
export function PageShell({ children, gap = 3, fill = false, className }) {
|
||||
return (
|
||||
<>
|
||||
{fill && (
|
||||
@@ -69,7 +75,12 @@ export function PageShell({ children, gap = 6, fill = false }) {
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<VStack gap={gap} padding={0} className={fill ? FILL_CLASS : undefined} style={fill ? { flex: 1, minHeight: 0 } : undefined}>
|
||||
<VStack
|
||||
gap={gap}
|
||||
padding={0}
|
||||
className={[fill ? FILL_CLASS : null, className].filter(Boolean).join(' ') || undefined}
|
||||
style={fill ? { flex: 1, minHeight: 0 } : undefined}
|
||||
>
|
||||
{children}
|
||||
</VStack>
|
||||
</>
|
||||
@@ -79,7 +90,8 @@ export function PageShell({ children, gap = 6, fill = false }) {
|
||||
PageShell.propTypes = {
|
||||
children: PropTypes.node,
|
||||
gap: PropTypes.number,
|
||||
fill: PropTypes.bool
|
||||
fill: PropTypes.bool,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,11 +106,21 @@ PageShell.propTypes = {
|
||||
//
|
||||
// `max` caps the column count so 3 cards don't each become 400px wide on an
|
||||
// ultrawide monitor; it defaults to the number of children.
|
||||
//
|
||||
// minWidth is 200, fitting four across smoothly while keeping labels single-line.
|
||||
//
|
||||
// gap is 3 (12px) — clear, visible separation between cards.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function KpiGrid({ children, minWidth = 240, max }) {
|
||||
export function KpiGrid({ children, minWidth = 200, gap = 3, max, className = '', style = {} }) {
|
||||
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
|
||||
return (
|
||||
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={4}>
|
||||
<Grid
|
||||
columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }}
|
||||
gap={gap}
|
||||
width="100%"
|
||||
className={['dt-kpi-grid', className].filter(Boolean).join(' ') || undefined}
|
||||
style={{ gap: 12, width: '100%', ...style }}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
@@ -107,7 +129,10 @@ export function KpiGrid({ children, minWidth = 240, max }) {
|
||||
KpiGrid.propTypes = {
|
||||
children: PropTypes.node,
|
||||
minWidth: PropTypes.number,
|
||||
max: PropTypes.number
|
||||
gap: PropTypes.number,
|
||||
max: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -125,7 +150,13 @@ KpiGrid.propTypes = {
|
||||
// ---------------------------------------------------------------------------
|
||||
export function FilterToolbar({ children, minWidth = 220, max = 3, padding = 4 }) {
|
||||
return (
|
||||
<VStack padding={padding} gap={0}>
|
||||
// `dt-filter-toolbar` is the hook the shared filter-control styling keys
|
||||
// off (see globalPolish.css). Scoped to this component on purpose: a
|
||||
// Selector in a FILTER row is a chip and should read as one, while the
|
||||
// same Selector in a form or dialog is a field and must keep the field
|
||||
// shape. Styling `.astryx-selector` globally would flatten that
|
||||
// distinction.
|
||||
<VStack className="dt-filter-toolbar" padding={padding} gap={0}>
|
||||
<Grid columns={{ minWidth, max, repeat: 'fit' }} gap={4}>
|
||||
{children}
|
||||
</Grid>
|
||||
@@ -158,12 +189,29 @@ FilterToolbar.propTypes = {
|
||||
// bottom of the screen. A table inside then only needs `maxHeight="100%"` —
|
||||
// no viewport arithmetic, and it re-measures on every resize for free.
|
||||
// ---------------------------------------------------------------------------
|
||||
// The one place the table container's outline is decided, for all 17 pages
|
||||
// that use DataCard. It is stated explicitly rather than inherited so the
|
||||
// result is deterministic: Astryx has two neutral border tokens
|
||||
// (`--color-border` at ~10% alpha and `--color-border-emphasized` at #CCD3DB)
|
||||
// and which one a Card resolves to depends on its elevation and variant. A
|
||||
// table frame that changes weight depending on that is exactly the
|
||||
// inconsistency this is meant to remove.
|
||||
//
|
||||
// DT.borderSubtle (#e2e8f0) is the existing design-system neutral — no new
|
||||
// colour is introduced, and it is the same value the sticky table header and
|
||||
// the row separators use, so no internal line is ever darker than the frame.
|
||||
export const TABLE_FRAME = `1px solid ${DT.borderSubtle}`;
|
||||
|
||||
export function DataCard({ toolbar, tabs, children, fill = false }) {
|
||||
return (
|
||||
<Card
|
||||
padding={0}
|
||||
elevation="none"
|
||||
style={fill ? { overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : { overflow: 'hidden' }}
|
||||
style={
|
||||
fill
|
||||
? { border: TABLE_FRAME, overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }
|
||||
: { border: TABLE_FRAME, overflow: 'hidden' }
|
||||
}
|
||||
>
|
||||
{toolbar}
|
||||
{toolbar && <Divider />}
|
||||
|
||||
101
src/components/nearle_components/SegmentCard.js
Normal file
101
src/components/nearle_components/SegmentCard.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
// ==============================|| SEGMENT CARD ||============================== //
|
||||
//
|
||||
// A selectable band: a named range of something, how many fall in it, and one
|
||||
// line of supporting detail. Clicking it filters the page to that band.
|
||||
//
|
||||
// ┌─────────────────────────────┐
|
||||
// │ PENDING awaiting rider [◷]│ label + range, icon chip
|
||||
// │ 12 │ count, in the band's own colour
|
||||
// │ ( 12 orders · 3 late ) │ meta pill
|
||||
// │ ──────────────────────── │ colour rule
|
||||
// └─────────────────────────────┘
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// This is NOT StatCard, deliberately
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatCard is a passive metric strip, kept ultra-compact (48px) because a KPI
|
||||
// row is supporting information above a table and should cost as little
|
||||
// vertical space as possible.
|
||||
//
|
||||
// This is the opposite job. A segment is a CONTROL — it carries a selected
|
||||
// state, it is clickable, and it has to hold four pieces of information rather
|
||||
// than two. It earns its height because acting on it is the point, not
|
||||
// glancing at it. Putting both jobs in one component would mean one of them
|
||||
// getting the wrong shape.
|
||||
//
|
||||
// Use StatCard for "here is the number". Use this for "pick a band".
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colour
|
||||
// ---------------------------------------------------------------------------
|
||||
// `color` is a caller-supplied band accent — the same sanctioned raw-hex
|
||||
// escape hatch StatCard uses (folder CLAUDE.md §2), because the token system
|
||||
// cannot express "whatever this particular band means". It appears in exactly
|
||||
// three places: the count, the icon chip tint, and the rule along the bottom.
|
||||
// The card's own frame stays neutral so a row of five reads as one set.
|
||||
|
||||
const SegmentCard = ({ label, range, value, meta, icon, color = DT.brand, isSelected = false, onClick }) => {
|
||||
const isInteractive = typeof onClick === 'function';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`dt-segment-card${isSelected ? ' is-selected' : ''}`}
|
||||
onClick={onClick}
|
||||
disabled={!isInteractive}
|
||||
aria-pressed={isInteractive ? isSelected : undefined}
|
||||
style={{
|
||||
// The selected border is the band's own colour, so which one is active
|
||||
// is legible without reading the labels.
|
||||
borderColor: isSelected ? color : DT.borderSubtle,
|
||||
cursor: isInteractive ? 'pointer' : 'default'
|
||||
}}
|
||||
>
|
||||
<span className="dt-segment-head">
|
||||
<span className="dt-segment-names">
|
||||
<span className="dt-segment-label">{label}</span>
|
||||
{range && <span className="dt-segment-range">{range}</span>}
|
||||
</span>
|
||||
{icon && (
|
||||
<span className="dt-segment-icon" style={{ backgroundColor: `${color}14`, color }} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="dt-segment-value" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
|
||||
{meta && <span className="dt-segment-meta">{meta}</span>}
|
||||
|
||||
{/* The rule is the band's signature — the one place the colour runs the
|
||||
full width, so a row of cards reads as a spectrum at a glance. */}
|
||||
<span className="dt-segment-rule" style={{ backgroundColor: color }} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SegmentCard.propTypes = {
|
||||
/** Band name — rendered uppercase, e.g. "Pending". */
|
||||
label: PropTypes.node,
|
||||
/** The band's definition, e.g. "90+" or "awaiting rider". */
|
||||
range: PropTypes.node,
|
||||
/** The count. The largest thing on the card. */
|
||||
value: PropTypes.node,
|
||||
/** One line of supporting detail, shown as a pill. */
|
||||
meta: PropTypes.node,
|
||||
/** Rendered node, e.g. icon={<MdTimer size={16} />}. */
|
||||
icon: PropTypes.node,
|
||||
/** Band accent hex. Colours the count, the icon chip and the rule. */
|
||||
color: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
/** Omit to render a non-interactive card. */
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
export default SegmentCard;
|
||||
@@ -1,76 +1,104 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||
import { DT, STATUS } from 'themes/dt/tokens';
|
||||
|
||||
// ==============================|| STAT / KPI CARD (Doormile-style) ||============================== //
|
||||
// Clean metric card: muted eyebrow label, large tabular value, a single
|
||||
// soft-tinted rounded tile holding the icon (with a faint matching ring for
|
||||
// depth). A low resting elevation lifts the tile off the page instead of
|
||||
// reading as a flat bordered box. No coloured top-stripe, no rainbow —
|
||||
// colour appears only in the icon tile so a row of cards reads calm and
|
||||
// corporate.
|
||||
// ==============================|| STAT / KPI CARD ||============================== //
|
||||
//
|
||||
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
|
||||
// `color` is a hex accent (defaults to brand red); `caption` is the small
|
||||
// muted line under the value (e.g. "96% of total").
|
||||
//
|
||||
// Astryx note: the icon tile keeps an inline `style` because the accent is a
|
||||
// per-call hex from the caller (each KPI tile carries its own semantic status
|
||||
// colour — see CLAUDE.md's status palette), which Astryx's fixed `variant`
|
||||
// and semantic `color` enums can't express. Same escape hatch login.js uses
|
||||
// for the brand gradient panel.
|
||||
// Replica of the Orders page KPI / SegmentCard design across all console pages.
|
||||
// Structure:
|
||||
// - Header: uppercase bold label + optional range/caption on left, 28x28 icon chip on right
|
||||
// - Value: 24px bold count in the accent colour
|
||||
// - Meta: optional pill badge
|
||||
// - Rule: inset 2px accent line along the bottom
|
||||
// - Active state: full border in the accent colour when selected
|
||||
|
||||
const VARIANTS = {
|
||||
neutral: STATUS.muted,
|
||||
brand: DT.brand,
|
||||
blue: STATUS.info,
|
||||
green: STATUS.delivered,
|
||||
amber: STATUS.pending,
|
||||
red: STATUS.cancelled
|
||||
};
|
||||
|
||||
export default function StatCard({
|
||||
title,
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
variant = 'brand',
|
||||
caption,
|
||||
meta,
|
||||
range,
|
||||
loading = false,
|
||||
onClick,
|
||||
clickable,
|
||||
isSelected = false,
|
||||
className = '',
|
||||
style = {}
|
||||
}) {
|
||||
const accent = color || VARIANTS[variant] || DT.brand;
|
||||
const isInteractive = typeof onClick === 'function' || clickable;
|
||||
const displayLabel = label ?? title;
|
||||
const displayRange = range ?? caption;
|
||||
|
||||
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
|
||||
return (
|
||||
<Card height="100%" padding={2} elevation="low">
|
||||
<HStack justify="between" vAlign="center" gap={2}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0} width="100%">
|
||||
<Text type="supporting" size="xsm" weight="semibold" maxLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Skeleton width={60} height={26} radius={2} />
|
||||
) : (
|
||||
<Text size="xl" weight="bold" display="block" maxLines={1} hasTabularNumbers>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{caption && (
|
||||
<Text type="supporting" size="sm" display="block">
|
||||
{caption}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<button
|
||||
type="button"
|
||||
className={`dt-segment-card dt-stat-card${isSelected ? ' is-selected' : ''}${className ? ` ${className}` : ''}`}
|
||||
onClick={onClick}
|
||||
disabled={!isInteractive}
|
||||
aria-pressed={isInteractive ? isSelected : undefined}
|
||||
style={{
|
||||
borderColor: isSelected ? accent : DT.borderSubtle,
|
||||
cursor: isInteractive ? 'pointer' : 'default',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<span className="dt-segment-head">
|
||||
<span className="dt-segment-names">
|
||||
<span className="dt-segment-label">{displayLabel}</span>
|
||||
{displayRange && <span className="dt-segment-range">{displayRange}</span>}
|
||||
</span>
|
||||
{icon && (
|
||||
<Center
|
||||
width={36}
|
||||
height={36}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: 'var(--radius-element)',
|
||||
backgroundColor: `${color}16`,
|
||||
color,
|
||||
border: `1px solid ${color}2A`
|
||||
}}
|
||||
>
|
||||
<span className="dt-segment-icon" style={{ backgroundColor: `${accent}14`, color: accent }} aria-hidden="true">
|
||||
{icon}
|
||||
</Center>
|
||||
</span>
|
||||
)}
|
||||
</HStack>
|
||||
</Card>
|
||||
</span>
|
||||
|
||||
{loading ? (
|
||||
<Skeleton width={50} height={24} radius={DT.radiusSm} />
|
||||
) : (
|
||||
<span className="dt-segment-value" style={{ color: accent }}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{meta && <span className="dt-segment-meta">{meta}</span>}
|
||||
|
||||
<span className="dt-segment-rule" style={{ backgroundColor: accent }} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
StatCard.propTypes = {
|
||||
title: PropTypes.node,
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.node,
|
||||
icon: PropTypes.node,
|
||||
color: PropTypes.string,
|
||||
variant: PropTypes.oneOf(['neutral', 'brand', 'blue', 'green', 'amber', 'red']),
|
||||
caption: PropTypes.node,
|
||||
loading: PropTypes.bool
|
||||
meta: PropTypes.node,
|
||||
range: PropTypes.node,
|
||||
loading: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
clickable: PropTypes.bool,
|
||||
isSelected: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object
|
||||
};
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { TabList, Tab } from '@astryxdesign/core/TabList';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Grid } from '@astryxdesign/core/Grid';
|
||||
|
||||
import { getStatusMeta } from 'themes/dt/status';
|
||||
import SegmentCard from './SegmentCard';
|
||||
|
||||
// ==============================|| STATUS TABS ||============================== //
|
||||
// The top-level status filter strip on every list page.
|
||||
//
|
||||
// Under MUI these were hand-rolled clickable <Box> pills, because MUI's <Tabs>
|
||||
// couldn't carry a count chip or a per-tab accent. Astryx's Tab has an
|
||||
// `endContent` slot and a `selectedIcon`, so the pills are now real tabs —
|
||||
// which also means arrow-key navigation and correct `role="tab"` semantics
|
||||
// come for free instead of being missing (the old pills were plain divs with
|
||||
// onClick, which is half of Dispatch.js's outstanding a11y lint errors).
|
||||
// The top-level status filter on every list page.
|
||||
//
|
||||
// <StatusTabs
|
||||
// value={currentStatus}
|
||||
@@ -24,34 +17,75 @@ import { getStatusMeta } from 'themes/dt/status';
|
||||
// ]}
|
||||
// />
|
||||
//
|
||||
// Each tab's `status` resolves through the shared registry for its label and
|
||||
// icon, so a page only supplies the ORDER of the tabs and where each count
|
||||
// comes from. Pass `label` on an entry to override the registry wording.
|
||||
// Each entry's `status` resolves through the shared registry for its label,
|
||||
// icon, colour and hint, so a page supplies only the ORDER of the bands and
|
||||
// where each count comes from. Pass `label` on an entry to override the
|
||||
// registry wording.
|
||||
//
|
||||
// Counts render as a neutral Badge — informational, not urgent. Astryx's Badge
|
||||
// guidance reserves the loud `error` variant for counts that demand action, so
|
||||
// a page that wants that (an exceptions queue, say) passes `isUrgent` on the
|
||||
// entry rather than every count screaming by default.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cards, not a tab strip
|
||||
// ---------------------------------------------------------------------------
|
||||
// This was a row of Astryx <Tab>s with a count badge. It is now a row of
|
||||
// <SegmentCard>s: same props, same behaviour, same registry — only the
|
||||
// rendering changed, so no page file needed touching.
|
||||
//
|
||||
// The strip was doing three jobs with one line of text: naming the band,
|
||||
// carrying its count, and being the control that selects it. As cards each of
|
||||
// those gets its own place — the name, a hint at what the band MEANS, the
|
||||
// count as the largest thing, and the band's share of the total. The selected
|
||||
// band is now legible from across the room by its coloured border rather than
|
||||
// from a 2px underline.
|
||||
//
|
||||
// The cost is honest: a card row is taller than a tab strip. That is why the
|
||||
// count is the dominant element — the row has to earn the height by being
|
||||
// readable at a glance, not just clickable.
|
||||
//
|
||||
// `size` and `hasDivider` are accepted and ignored. They were part of the
|
||||
// TabList API and several pages still pass them; silently accepting them
|
||||
// keeps those call sites working rather than spreading unknown props onto a
|
||||
// DOM node (see this folder's CLAUDE.md §7 on prop-signature changes).
|
||||
|
||||
export default function StatusTabs({ value, onChange, tabs, hideHint }) {
|
||||
// The pill under each count is the band's share of everything shown. It is
|
||||
// computed from the tabs themselves rather than taken as a prop, so it can
|
||||
// never disagree with the numbers printed above it.
|
||||
const total = tabs.reduce((sum, tab) => sum + (Number(tab.count) || 0), 0);
|
||||
|
||||
export default function StatusTabs({ value, onChange, tabs, size = 'md', hasDivider = true }) {
|
||||
return (
|
||||
<TabList value={value} onChange={onChange} size={size} hasDivider={hasDivider}>
|
||||
// width="100%" is load-bearing. DataCard renders this slot inside a VStack,
|
||||
// which aligns its children to the start rather than stretching them, so
|
||||
// without it the Grid shrinks to a single minWidth column and all eight
|
||||
// bands stack vertically in a container that had room for six across.
|
||||
<Grid
|
||||
columns={{ minWidth: 125, max: Math.max(tabs.length, 1), repeat: 'fit' }}
|
||||
gap={2}
|
||||
width="100%"
|
||||
className="dt-status-tabs-grid"
|
||||
style={{ gap: 8 }}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const meta = getStatusMeta(tab.status);
|
||||
const Icon = meta.icon;
|
||||
const hasCount = tab.count !== undefined && tab.count !== null;
|
||||
const count = Number(tab.count) || 0;
|
||||
// No share line when there is nothing to take a share of — "0% of 0"
|
||||
// is noise, and an empty band should simply say nothing.
|
||||
const share = total > 0 ? `${Math.round((count / total) * 100)}% of ${total}` : null;
|
||||
|
||||
return (
|
||||
<Tab
|
||||
<SegmentCard
|
||||
key={tab.status}
|
||||
value={tab.status}
|
||||
label={tab.label ?? meta.label}
|
||||
icon={<Icon size={15} />}
|
||||
endContent={hasCount ? <Badge variant={tab.isUrgent ? 'error' : 'neutral'} label={tab.count} /> : undefined}
|
||||
range={hideHint ? null : meta.hint}
|
||||
value={count}
|
||||
meta={share}
|
||||
icon={<Icon size={16} />}
|
||||
color={meta.color}
|
||||
isSelected={value === tab.status}
|
||||
onClick={() => onChange?.(tab.status)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +100,10 @@ StatusTabs.propTypes = {
|
||||
isUrgent: PropTypes.bool
|
||||
})
|
||||
).isRequired,
|
||||
/** Accepted for call-site compatibility with the previous TabList API. */
|
||||
size: PropTypes.oneOf(['sm', 'md', 'lg']),
|
||||
hasDivider: PropTypes.bool
|
||||
/** Accepted for call-site compatibility with the previous TabList API. */
|
||||
hasDivider: PropTypes.bool,
|
||||
/** If true, hides the hint text (meta.hint) on the cards. */
|
||||
hideHint: PropTypes.bool
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ const NOTIFICATION_STYLE = {
|
||||
left: '50%', // Center horizontally
|
||||
transform: 'translateX(-50%)', // Offset to truly center
|
||||
padding: '12px 40px',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '5px',
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/* Global visual polish that used to come from MUI's <CssBaseline>/<GlobalStyles>
|
||||
in themes/index.js (ThemeCustomization). Kept as plain CSS now that every
|
||||
page runs on Astryx — see root CLAUDE.md §6.6. Astryx's own reset
|
||||
page runs on Astryx — see root CLAUDE.md §6. Astryx's own reset
|
||||
(@astryxdesign/core/reset.css, imported in index.js) covers the base
|
||||
element resets; this file only carries the two enterprise-polish touches
|
||||
that reset doesn't: crisp font rendering and a thin, unobtrusive scrollbar. */
|
||||
element resets; this file carries the handful of things a design token
|
||||
cannot express: font rendering, scrollbar pseudo-elements, keyframes, the
|
||||
focus treatment, and the global reduced-motion rule.
|
||||
|
||||
NOTE ON COLOUR: nothing here introduces a colour. The console keeps its own
|
||||
palette (themes/dt/tokens.js) — the KROW work adopted KROW's geometry and
|
||||
type treatment only. Where a rule below needs an accent it reads
|
||||
`var(--color-accent)` rather than naming one. */
|
||||
|
||||
body {
|
||||
/* Astryx sets --font-family-body on :root (from themes/astryx.js's
|
||||
@@ -19,11 +25,58 @@ body {
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Scrollbars are chrome, not content — they should recede until used.
|
||||
The thumb was rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the
|
||||
right edge of every scrollable table and read as a heavy border rather than
|
||||
as a scrollbar. Lightened and narrowed; it still darkens on hover so it stays
|
||||
findable. */
|
||||
/* --------------------------------------------------------------------------
|
||||
Motion vocabulary.
|
||||
|
||||
One set of durations and one easing curve for the whole app, so a hover, a
|
||||
row highlight and a card entrance all feel like the same product. Three
|
||||
steps only: `fast` for colour-only changes (anything slower feels laggy
|
||||
under a moving cursor), `base` for the default, `slow` for layout and
|
||||
entrances.
|
||||
|
||||
Prefixed --dt-* deliberately. These live on :root, and Dispatch.css ALSO
|
||||
writes :root from a lazy-loaded chunk — an unprefixed name there would win
|
||||
or lose purely on bundle order rather than on intent.
|
||||
-------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--dt-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--dt-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--dt-duration-fast: 120ms;
|
||||
--dt-duration-base: 200ms;
|
||||
--dt-duration-slow: 320ms;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Focus.
|
||||
|
||||
Focus is never the browser default. One ring, one width, everywhere. The
|
||||
colour is the app's own accent token, not a value chosen here, so the ring
|
||||
follows the brand automatically.
|
||||
|
||||
:focus-visible, not :focus — a mouse click on a button should not leave a
|
||||
ring behind it.
|
||||
-------------------------------------------------------------------------- */
|
||||
.dt-focus-ring:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px var(--color-accent);
|
||||
}
|
||||
|
||||
/* The inset variant, for a control flush inside a row or table cell where an
|
||||
offset ring would be clipped by the overflow container. */
|
||||
.dt-focus-ring-inset:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Scrollbars.
|
||||
|
||||
Chrome, not content — they should recede until used. The thumb was
|
||||
rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the right edge of
|
||||
every scrollable table and read as a heavy border rather than as a
|
||||
scrollbar. Lightened and narrowed; it still darkens on hover so it stays
|
||||
findable.
|
||||
-------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgba(0, 0, 0, 0.16) transparent;
|
||||
@@ -40,7 +93,7 @@ body {
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.16);
|
||||
border-radius: 8px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -52,3 +105,287 @@ body {
|
||||
*::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Keyframes.
|
||||
|
||||
A skeleton SHIMMERS, it does not pulse: a pulsing block reads as a
|
||||
rendering glitch, while a travelling highlight reads as progress. Astryx's
|
||||
own <Skeleton> keeps its built-in treatment; this is for the hand-rolled
|
||||
placeholders.
|
||||
-------------------------------------------------------------------------- */
|
||||
@keyframes dt-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dt-shimmer {
|
||||
background-image: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.65) 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: dt-shimmer 1.6s var(--dt-ease-in-out) infinite;
|
||||
}
|
||||
|
||||
@keyframes dt-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dt-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dt-fade-in {
|
||||
animation: dt-fade-in var(--dt-duration-base) var(--dt-ease-out);
|
||||
}
|
||||
|
||||
.dt-slide-up {
|
||||
animation: dt-slide-up var(--dt-duration-slow) var(--dt-ease-out);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Segment card (components/nearle_components/SegmentCard.js).
|
||||
|
||||
A selectable band. Structure is a flex column so the meta pill and the
|
||||
colour rule sit at fixed points regardless of how long the label runs, and
|
||||
the rule is pinned to the bottom rather than following the content.
|
||||
-------------------------------------------------------------------------- */
|
||||
.dt-segment-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
padding: 14px 16px 18px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
transition: border-color var(--dt-duration-base) var(--dt-ease-out), box-shadow var(--dt-duration-base) var(--dt-ease-out);
|
||||
}
|
||||
|
||||
.dt-segment-card:not(:disabled):hover {
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.dt-segment-card:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
/* Selected reads through the border colour, set inline from the band accent.
|
||||
A second cue would be redundant — the colour is already unique per band. */
|
||||
.dt-segment-card.is-selected {
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.dt-segment-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dt-segment-names {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dt-segment-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dt-segment-range {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dt-segment-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dt-segment-icon svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.dt-segment-value {
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dt-segment-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Inset from the edges so it reads as a rule belonging to the card, not as a
|
||||
border on it. */
|
||||
.dt-segment-rule {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 10px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Ensure consistent 12px spacing between KPI cards */
|
||||
.dt-kpi-grid {
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Filter row.
|
||||
|
||||
Every control in a FilterToolbar is a filter, so they all take the same
|
||||
shape and the same height — the row reads as one set of controls rather
|
||||
than as a Selector, a Button and a search field that happen to sit in a
|
||||
line.
|
||||
|
||||
Only the SHAPE and the hairline move. The fills and text colours are
|
||||
Astryx's own, so nothing here changes the palette.
|
||||
-------------------------------------------------------------------------- */
|
||||
.astryx-selector,
|
||||
.astryx-selector-trigger,
|
||||
.astryx-selector button,
|
||||
.astryx-selector [role='combobox'],
|
||||
.astryx-text-input,
|
||||
.astryx-text-input > div,
|
||||
.astryx-text-input input,
|
||||
.astryx-button,
|
||||
button.astryx-button,
|
||||
.dt-filter-toolbar .astryx-selector,
|
||||
.dt-filter-toolbar .astryx-text-input,
|
||||
.dt-filter-toolbar .astryx-button,
|
||||
.dt-deliveries-toolbar .astryx-selector,
|
||||
.dt-deliveries-toolbar .astryx-text-input,
|
||||
.dt-deliveries-toolbar .astryx-button {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.dt-filter-toolbar .astryx-selector,
|
||||
.dt-filter-toolbar .astryx-text-input,
|
||||
.dt-filter-toolbar .astryx-button,
|
||||
.dt-deliveries-toolbar .astryx-selector,
|
||||
.dt-deliveries-toolbar .astryx-text-input,
|
||||
.dt-deliveries-toolbar .astryx-button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* The console's own hairline (DT.borderSubtle), the same one every card and
|
||||
table frame uses — so a filter control sits on the page at the same weight
|
||||
as the surfaces around it. */
|
||||
.dt-filter-toolbar .astryx-selector,
|
||||
.dt-filter-toolbar .astryx-text-input,
|
||||
.dt-deliveries-toolbar .astryx-selector,
|
||||
.dt-deliveries-toolbar .astryx-text-input {
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dt-filter-toolbar .astryx-selector,
|
||||
.dt-filter-toolbar .astryx-button,
|
||||
.dt-deliveries-toolbar .astryx-selector,
|
||||
.dt-deliveries-toolbar .astryx-button {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
KPI / StatCard (Compact Enterprise Metric Strip).
|
||||
|
||||
Static tiles stay quiet and static. Clickable/interactive tiles (e.g. on
|
||||
Tenants page) get a subtle 150ms hover lift and border emphasis.
|
||||
-------------------------------------------------------------------------- */
|
||||
.dt-stat-card {
|
||||
transition: box-shadow 150ms ease, border-color 150ms ease, transform 150ms ease !important;
|
||||
}
|
||||
|
||||
/* Interactive hover state ONLY when clickable or wrapped in button role */
|
||||
.dt-stat-card.dt-stat-card-clickable:hover,
|
||||
.dt-stat-card[data-clickable='true']:hover,
|
||||
[role='button'] .dt-stat-card:hover,
|
||||
[role='button'] > .dt-stat-card:hover,
|
||||
button .dt-stat-card:hover {
|
||||
border-color: var(--color-border-emphasized, #cbd5e1) !important;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dt-stat-card.dt-stat-card-clickable:active,
|
||||
.dt-stat-card[data-clickable='true']:active,
|
||||
[role='button'] .dt-stat-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Standardized 14px icon size inside the 26px chip across all call sites */
|
||||
.dt-stat-card .dt-stat-icon svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Reduced motion.
|
||||
|
||||
Handled ONCE, globally, rather than per component — a per-component opt-out
|
||||
is a rule that only covers the components somebody remembered.
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,17 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
|
||||
collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
|
||||
style={{
|
||||
backgroundColor: DT.surface,
|
||||
borderRight: `1px solid ${DT.borderSubtle}`,
|
||||
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
||||
// One hairline, and nothing else. This edge previously carried a
|
||||
// 1px borderSubtle line AND a box-shadow bleeding 3px to its right;
|
||||
// stacked, they read as a single heavy rule rather than the quiet
|
||||
// separation the chrome is supposed to have.
|
||||
//
|
||||
// The shadow is gone rather than softened — a nav pinned to the
|
||||
// viewport edge is not floating above anything, so the depth cue was
|
||||
// describing a relationship that does not exist. `divider` is the
|
||||
// lighter of the two border tokens; `borderSubtle` stays untouched
|
||||
// because 35 other call sites depend on its current weight.
|
||||
borderRight: '1px solid rgba(0, 0, 0, 0.015)',
|
||||
paddingBlock: '12px',
|
||||
paddingInline: '8px',
|
||||
boxSizing: 'border-box',
|
||||
@@ -88,7 +97,17 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] > * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
/* COLLAPSED rail — the icon is the only thing left, so it carries the
|
||||
whole item and has to be legible on its own.
|
||||
|
||||
font-size is the load-bearing property here, not width/height.
|
||||
Astryx renders the glyph as an svg sized in em units, so the width and
|
||||
height below only ever sized the WRAPPER: the box was 18px while the
|
||||
glyph inside it stayed at the inherited 14px, leaving 2px of dead
|
||||
space on every side and an icon that read small in a 40px target.
|
||||
Setting the font-size is what actually scales the mark. */
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
margin: 0 !important;
|
||||
@@ -96,6 +115,21 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
/* EXPANDED rail — the label does the identifying, so the icon only has
|
||||
to sit comfortably beside it. Still bumped off the inherited 14px,
|
||||
which was undersized against a 14px label. */
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
||||
background-color: ${DT.brand}14 !important;
|
||||
}
|
||||
|
||||
@@ -98,4 +98,5 @@ export const someFetch = async () => {
|
||||
|
||||
- Several `api.js` functions ignore most of their destructured `queryKey`/arguments — the new endpoint doesn't accept those filters. This is deliberate degradation, not a bug; see §3.
|
||||
- A handful of pages (`createorder1.js`, `multipleOrders.js`) synthesize fallback data client-side (e.g. a fixed 09:00–21:00 delivery-slot window) because the endpoint that used to supply it no longer exists. Look for the comment explaining why before "fixing" it.
|
||||
- **`updateDeliveryAPI` refuses half the Deliveries dialog's options, on purpose.** It writes to `PUT /admin/consignments/:id/status`, which speaks the **consignment** enum — not the booking enum they look like. `Picked` maps to `Converted_To_Consignment`, which names the *moment a booking becomes a consignment*; a consignment cannot be set to it, so choosing Picked did nothing and the row kept showing the consignment's real state. `Pending` and `Accepted` describe a booking *before* pickup, which a consignment can never return to. Only `Out_for_Delivery`, `Delivered` and `Cancelled` are sent; everything else is refused with a reason and **nothing goes on the wire**. If the real consignment enum is ever captured, widen the map — don't guess a value.
|
||||
- `notifyRider` / `notifyMiler` now take a **miler profile ID**, not an FCM token — the new API's `/admin/milers/:id/notify` looks the device token up server-side. Don't pass a token here.
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getHubs,
|
||||
getBookings,
|
||||
getBooking,
|
||||
getConsignments,
|
||||
assignMilerToBooking,
|
||||
cancelBooking,
|
||||
updateConsignmentStatus,
|
||||
@@ -46,9 +47,14 @@ import {
|
||||
// right after an assign-miler call — see orders.js's ORDERS_STATUS_TABS
|
||||
// comment) are confirmed; the rest of this mapping is still a best-effort
|
||||
// guess. Unmapped statuses pass through lowercased, which the page's own
|
||||
// fallback renders as an "unknown" badge rather than crashing. There's no
|
||||
// confirmed equivalent for arrived/picked/skipped at all, so those tabs will
|
||||
// show a 0 count until the real enum is confirmed.
|
||||
// fallback renders as an "unknown" badge rather than crashing, AND the
|
||||
// Deliveries page now surfaces those rows under an "Other" tab that names the
|
||||
// unmapped enum — previously such a row was counted nowhere and shown nowhere.
|
||||
// `picked` now has a confirmed source (Converted_To_Consignment, below).
|
||||
// `arrived` and `skipped` still have none: the rider actions that produce them
|
||||
// (POST /miler/bookings/:id/reached, POST /miler/consignments/:id/skip) have no
|
||||
// booking-status equivalent in the confirmed enum, so those two tabs stay at 0
|
||||
// until the backend is confirmed to expose them.
|
||||
//
|
||||
// Miler_Assigned is deliberately kept on 'pending', NOT bumped to 'accepted'.
|
||||
// Assigning a rider is an OPERATOR action (POST /admin/bookings/:id/assign-miler
|
||||
@@ -62,11 +68,20 @@ import {
|
||||
// accepted" in the currently-confirmed enum, so that one maps to 'accepted'.
|
||||
// If the backend turns out to have a distinct status specifically for the
|
||||
// accept action, add it here rather than reusing Miler_Assigned for it.
|
||||
// Converted_To_Consignment is PICKED, not accepted. doormile-flow.md §5 is
|
||||
// explicit: `POST /miler/bookings/:bookingid/pickup-complete` "converts the
|
||||
// booking into a consignment" — so the status is written at the moment the
|
||||
// rider completes pickup, and the parcel is in their hands. deliveries.js says
|
||||
// the same thing from the other side ("this order hasn't been picked up yet —
|
||||
// status can only be updated once it becomes a consignment").
|
||||
//
|
||||
// It sat on 'accepted', which held a picked-up parcel in the Accepted tab and
|
||||
// left Picked permanently empty — nothing else in the enum maps to it.
|
||||
const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
pending_pickup: 'pending',
|
||||
miler_assigned: 'pending',
|
||||
pickup_scheduled: 'accepted',
|
||||
converted_to_consignment: 'accepted',
|
||||
converted_to_consignment: 'picked',
|
||||
out_for_delivery: 'active',
|
||||
delivered: 'delivered',
|
||||
cancelled: 'cancelled'
|
||||
@@ -80,6 +95,19 @@ export const mapBookingStatusToDeliveryStatus = (status) => {
|
||||
return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key;
|
||||
};
|
||||
|
||||
// A consignment's own status, when this booking has one and it looks like a
|
||||
// status. `GET /admin/consignments` has no documented response schema, so this
|
||||
// reads defensively: only a non-empty string on one of the plausible field
|
||||
// names counts, and anything else returns undefined so the caller keeps using
|
||||
// the booking's status.
|
||||
const consignmentStatusFor = (booking, consignmentMap) => {
|
||||
if (!booking?.consignmentid || !consignmentMap?.size) return undefined;
|
||||
const record = consignmentMap.get(String(booking.consignmentid));
|
||||
if (!record) return undefined;
|
||||
const raw = record.status ?? record.consignmentstatus ?? record.currentstatus;
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
||||
};
|
||||
|
||||
// Haversine straight-line distance in km — used wherever a resource carries
|
||||
// lat/lng but no road-distance field (bookings, and by extension deliveries).
|
||||
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
@@ -135,7 +163,10 @@ export const getRiderPeriodicLogs = async (userid) => {
|
||||
// seeing every hub, unchanged. Fails open (full list) whenever the tenant's
|
||||
// city can't be determined or nothing matches, rather than ever locking an
|
||||
// operator out with an empty picker.
|
||||
const normCity = (s) => String(s || '').trim().toLowerCase();
|
||||
const normCity = (s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
export const fetchAppLocations = async () => {
|
||||
try {
|
||||
@@ -202,7 +233,16 @@ export const fetchPercentageData = async ({ queryKey }) => {
|
||||
};
|
||||
} catch (err) {
|
||||
OpenToast(err.response?.data?.message || err.message || 'Failed to load order percentages', 'error', 2000);
|
||||
return { created: '0', uncoveredOrders: '0', coveredOrders: '0', cancelled: '0', percentage1: '0', percentage2: '0', percentage3: '0', percentage4: '0' };
|
||||
return {
|
||||
created: '0',
|
||||
uncoveredOrders: '0',
|
||||
coveredOrders: '0',
|
||||
cancelled: '0',
|
||||
percentage1: '0',
|
||||
percentage2: '0',
|
||||
percentage3: '0',
|
||||
percentage4: '0'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,7 +367,10 @@ export const fetchBatchEfficiency = async ({ batch, tenantId }) => {
|
||||
// jupiter2doormile.md comparison). Falls back to matching the rider's NAME
|
||||
// (also echoed by the solver, see flattenRiders' rider_name) against each
|
||||
// miler's displayname/authname — the only other correlatable field.
|
||||
const normMilerName = (s) => String(s || '').trim().toLowerCase();
|
||||
const normMilerName = (s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
export const buildMilerLookup = (milers) => {
|
||||
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
|
||||
@@ -348,10 +391,10 @@ export const resolveMilerForOrder = (order, lookup) => {
|
||||
const matchedVia = lookup.byUserId.has(String(riderUserId))
|
||||
? 'userid'
|
||||
: lookup.byProfileId.has(String(riderUserId))
|
||||
? 'milerprofileid'
|
||||
: lookup.byName.has(normMilerName(riderName))
|
||||
? 'name'
|
||||
: null;
|
||||
? 'milerprofileid'
|
||||
: lookup.byName.has(normMilerName(riderName))
|
||||
? 'name'
|
||||
: null;
|
||||
const rider =
|
||||
lookup.byUserId.get(String(riderUserId)) ?? lookup.byProfileId.get(String(riderUserId)) ?? lookup.byName.get(normMilerName(riderName));
|
||||
return rider?.milerprofileid ? { rider, matchedVia } : null;
|
||||
@@ -407,7 +450,11 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
realBookingIds = new Set(realBookings.map((b) => String(b.bookingid)));
|
||||
logger.debug(`finalCreatedeliveries: ${realBookingIds.size} real booking id(s) fetched for validation`);
|
||||
} catch (err) {
|
||||
logger.error('finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids', err.response?.status, err.response?.data || err.message);
|
||||
logger.error(
|
||||
'finalCreatedeliveries: GET /admin/bookings failed — cannot validate booking ids',
|
||||
err.response?.status,
|
||||
err.response?.data || err.message
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
@@ -464,11 +511,7 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
if (failed.length) {
|
||||
OpenToast(`${failed.length} of ${deliveries.length} order(s) couldn't be assigned — check Orders/Deliveries`, 'warning', 4000);
|
||||
}
|
||||
const resolvedMilerProfileIds = [
|
||||
...new Set(
|
||||
results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid)
|
||||
)
|
||||
];
|
||||
const resolvedMilerProfileIds = [...new Set(results.filter((r) => r.status === 'fulfilled').map((r) => r.value.milerprofileid))];
|
||||
return { success: true, assigned: deliveries.length - failed.length, failed: failed.length, resolvedMilerProfileIds };
|
||||
};
|
||||
// ==============================|| createAutomationDeliveries (orders) Auto rider Assign ||============================== //
|
||||
@@ -549,11 +592,29 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
OpenToast(err.response?.data?.message || err.message || 'Failed to load deliveries', 'error', 2000);
|
||||
return { rows: [], nextPage: undefined };
|
||||
}
|
||||
const [customers, milers, tenants] = await Promise.all([
|
||||
// Consignments are joined for ONE reason: once a booking becomes a
|
||||
// consignment, its lifecycle continues on the CONSIGNMENT record and the
|
||||
// booking's own `status` stops moving. `PUT /admin/consignments/:id/status`
|
||||
// — the Update Status dialog — writes there, so the write succeeded, the
|
||||
// toast said so, and this page went on showing the booking's stale
|
||||
// `Converted_To_Consignment` because that is the only field it read.
|
||||
//
|
||||
// Guarded like the other three joins: if the call fails, or the response
|
||||
// carries no recognisable status, the row falls back to the booking's status
|
||||
// and behaviour is exactly what it was before this join existed.
|
||||
const [customers, milers, tenants, consignments] = await Promise.all([
|
||||
getAdminCustomers().catch(() => []),
|
||||
getMilers().catch(() => []),
|
||||
getAdminTenants().catch(() => [])
|
||||
getAdminTenants().catch(() => []),
|
||||
getConsignments().catch(() => [])
|
||||
]);
|
||||
// The id field on a consignment record has never been captured, so both
|
||||
// plausible names are indexed rather than guessing one.
|
||||
const consignmentMap = new Map();
|
||||
(consignments || []).forEach((c) => {
|
||||
const id = c?.consignmentid ?? c?.id;
|
||||
if (id != null) consignmentMap.set(String(id), c);
|
||||
});
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
|
||||
const milerMap = new Map((milers || []).map((m) => [m.userid ?? m.milerid, m]));
|
||||
const tenantMap = new Map((tenants || []).map((t) => [t.tenantid, t]));
|
||||
@@ -647,7 +708,19 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
// remains fine to DISPLAY assigntime as a "last updated" stamp, which is
|
||||
// all the reports use it for.
|
||||
assigntime: b.updatedat,
|
||||
orderstatus: mapBookingStatusToDeliveryStatus(b.status),
|
||||
// The consignment's status WINS when there is one. That is the record
|
||||
// the rider app and the Update Status dialog both advance; the booking's
|
||||
// status is frozen at Converted_To_Consignment from pickup onwards.
|
||||
// Falls back to the booking whenever the consignment is absent or carries
|
||||
// nothing status-shaped — never invents a state.
|
||||
orderstatus: mapBookingStatusToDeliveryStatus(consignmentStatusFor(b, consignmentMap) ?? b.status),
|
||||
// Which record the status above came from, and what it said. A booking
|
||||
// freezes at Converted_To_Consignment the moment it is picked up, so a
|
||||
// row can legitimately read "Active" while GET /admin/bookings still says
|
||||
// Converted_To_Consignment — which looks exactly like a bug unless the UI
|
||||
// can say where the value came from.
|
||||
consignmentstatus: consignmentStatusFor(b, consignmentMap),
|
||||
statusfromconsignment: consignmentStatusFor(b, consignmentMap) != null,
|
||||
droplat: b.deliverylatitude,
|
||||
droplon: b.deliverylongitude
|
||||
};
|
||||
@@ -665,6 +738,16 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
||||
//
|
||||
// A missing/blank bound means "unbounded on that side", which preserves the
|
||||
// old behaviour for any caller that doesn't pass real dates.
|
||||
//
|
||||
// ⛔ An "activity" basis — also admitting a row whose `assigntime`
|
||||
// (== `updatedat`) falls in the window — was tried and REVERTED. It let an
|
||||
// order created yesterday evening and merely touched today onto today's
|
||||
// board, but batch bucketing reads `orderdate`, so that row landed in
|
||||
// Evening Batch. The live result was "Evening 6" at 10:41 in the morning on a
|
||||
// day with no orders created at all. Admitting a row on one timestamp while
|
||||
// bucketing it on another cannot produce an honest batch count; if
|
||||
// carried-over work needs to be visible it needs its own bucket, not a
|
||||
// time-of-day wave it does not belong to.
|
||||
const inRange = (row) => {
|
||||
if (!startdate && !enddate) return true;
|
||||
const t = parseDoormileTimestamp(row.orderdate);
|
||||
@@ -761,7 +844,96 @@ export const changeRiderAPI = async (selectedRider, selectedRow) =>
|
||||
// truthy, so `deliveryid ?? consignmentid` never actually fell through to
|
||||
// consignmentid even when it was present, silently calling the endpoint
|
||||
// with the wrong kind of id on every Update Status submit.
|
||||
export const updateDeliveryAPI = async (orderData) => updateConsignmentStatus(orderData.consignmentid ?? orderData.deliveryid, orderData);
|
||||
//
|
||||
// ---- The body ---------------------------------------------------------------
|
||||
//
|
||||
// This used to forward the dialog's WHOLE state object as the request body —
|
||||
// the old jupiter shape (`orderstatus`, `deliveryid`, `orderheaderid`,
|
||||
// `deliveryamt`, `cumulativekms`, `userid`). The endpoint wants one field
|
||||
// called `status`, so every submit came back:
|
||||
//
|
||||
// PUT /admin/consignments/40/status → 400 {"status is required"}
|
||||
//
|
||||
// The status was in the payload the whole time, under the wrong name.
|
||||
//
|
||||
// The VALUE has to be translated too. The dialog's options are this page's own
|
||||
// lifecycle keys (`delivered`, `cancelled`, …); the API speaks the booking enum
|
||||
// (`Delivered`, `Cancelled`, …). Sending `delivered` where `Delivered` is
|
||||
// expected is the same class of bug one layer down.
|
||||
//
|
||||
// Reverse of BOOKING_STATUS_TO_DELIVERY_STATUS, and deliberately NOT derived
|
||||
// from it by inversion: that map is many-to-one (`pending_pickup` and
|
||||
// `miler_assigned` both mean `pending`), so an automatic inversion would pick
|
||||
// whichever happened to be last and silently write the wrong one.
|
||||
// ⚠ These are CONSIGNMENT statuses, not booking statuses. The endpoint is
|
||||
// `PUT /admin/consignments/:id/status`, and the two enums are not the same
|
||||
// vocabulary even though they overlap on Delivered and Cancelled.
|
||||
//
|
||||
// This map used to include the booking's pre-pickup states, and they were all
|
||||
// nonsense to send here:
|
||||
//
|
||||
// picked → 'Converted_To_Consignment' — that names the MOMENT a booking
|
||||
// becomes a consignment. A consignment cannot be set to it; it
|
||||
// already is one. Choosing "Picked" in the dialog therefore did
|
||||
// nothing and the row kept showing whatever the consignment
|
||||
// really was, which is what "I set it to picked and it still says
|
||||
// Active" was.
|
||||
// pending → 'Pending_Pickup'
|
||||
// accepted → 'Pickup_Scheduled' — both describe a booking BEFORE
|
||||
// pickup. A consignment only exists after it, so it can never go
|
||||
// back to either.
|
||||
//
|
||||
// What a consignment can actually be set to, per doormile-flow.md §5–6: it is
|
||||
// created at pickup-complete already Out_for_Delivery (hyperlocal) or routed
|
||||
// via a hub, then delivered, skipped, or cancelled.
|
||||
const DELIVERY_STATUS_TO_CONSIGNMENT_STATUS = {
|
||||
// The dialog offers "started"; a consignment that has started IS out for
|
||||
// delivery — this API has no separate state for it.
|
||||
started: 'Out_for_Delivery',
|
||||
active: 'Out_for_Delivery',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled',
|
||||
canceled: 'Cancelled'
|
||||
};
|
||||
|
||||
// Why a status can't be set, when it can't. Specific beats generic: "Picked
|
||||
// can't be set" is useless next to "it's already a consignment, which is what
|
||||
// picked means".
|
||||
const UNSETTABLE_REASON = {
|
||||
picked: 'this order is already a consignment — which is exactly what "picked" means. There is no earlier state to set it back to.',
|
||||
pending:
|
||||
'a consignment only exists after pickup, so it can’t go back to Pending — that describes a booking before the parcel was collected.',
|
||||
accepted:
|
||||
'a consignment only exists after pickup, so it can’t go back to Accepted — that describes a booking before the parcel was collected.',
|
||||
arrived: 'the rider action behind Arrived (POST /miler/bookings/:id/reached) writes no consignment status, so there is nothing to set.',
|
||||
skipped:
|
||||
'Skipped is recorded by the rider (POST /miler/consignments/:id/skip) as an attempt count, not as a status this endpoint can write.'
|
||||
};
|
||||
|
||||
export const updateDeliveryAPI = async (orderData) => {
|
||||
const id = orderData.consignmentid ?? orderData.deliveryid;
|
||||
const chosen = String(orderData.orderstatus || '').toLowerCase();
|
||||
const status = DELIVERY_STATUS_TO_CONSIGNMENT_STATUS[chosen];
|
||||
|
||||
// `arrived` and `skipped` have no booking-status equivalent at all (the rider
|
||||
// actions behind them — /miler/bookings/:id/reached and
|
||||
// /miler/consignments/:id/skip — write no booking status). Refusing here with
|
||||
// the reason is honest; guessing a near-enough status would set the wrong one
|
||||
// on a real delivery.
|
||||
if (!status) {
|
||||
if (!chosen) return { success: false, message: 'Choose a status first.' };
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
UNSETTABLE_REASON[chosen] || `"${orderData.orderstatus}" has no equivalent on the consignment API, so it can't be set from here.`
|
||||
};
|
||||
}
|
||||
|
||||
// Only `status` is sent. The dialog's kms / amount / notes have no field on
|
||||
// this endpoint (see the note above), and this request 400s on validation —
|
||||
// so posting the rest is at best ignored and at worst another rejection.
|
||||
return updateConsignmentStatus(id, { status });
|
||||
};
|
||||
|
||||
// ==============================|| getalltenants (tenants) ||============================== //
|
||||
|
||||
@@ -815,9 +987,7 @@ export const getallridersummary = async () => {
|
||||
// silently inflating one of the two new buckets.
|
||||
const active = milers.filter((m) => !['offline', 'blocked'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
|
||||
const available = milers.filter((m) => String(m.availabilitystatus || '').toLowerCase() === 'available').length;
|
||||
const onDelivery = milers.filter((m) =>
|
||||
['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())
|
||||
).length;
|
||||
const onDelivery = milers.filter((m) => ['assigned', 'on_pickup'].includes(String(m.availabilitystatus || '').toLowerCase())).length;
|
||||
return { total: milers.length, active, inactive: milers.length - active, available, onDelivery };
|
||||
} catch (err) {
|
||||
OpenToast(err.response?.data?.message || err.message || 'Failed to load rider summary', 'error', 2000);
|
||||
|
||||
@@ -33,6 +33,39 @@ export const logoutAdmin = () => {
|
||||
localStorage.removeItem(DOORMILE_USER_KEY);
|
||||
};
|
||||
|
||||
// Drains a paginated list endpoint instead of taking whatever the server's
|
||||
// default page size happens to be.
|
||||
//
|
||||
// GET /admin/customers defaults to `pagesize: 20` — confirmed live against an
|
||||
// account with 37 customers, which came back as
|
||||
// { pageno: 1, pagesize: 20, pages: 2, total: 37 }. Every caller of
|
||||
// getAdminCustomers was therefore seeing 20 of 37 and had no way to know:
|
||||
// the Customers page reported "Total Customers 20", and the customer lookups
|
||||
// on the orders pages silently missed anyone on page 2.
|
||||
//
|
||||
// (express-console-api.md claims the default is 500. It is not, at least not
|
||||
// for this endpoint. Trust the envelope, not the doc.)
|
||||
const PAGE_SIZE = 100; // the doc's own example value — safer than assuming the 1000 cap holds
|
||||
const MAX_PAGES = 12;
|
||||
|
||||
const fetchAllPages = async (path) => {
|
||||
const first = await doormileAxios.get(`${path}${buildQuery({ pageno: 1, pagesize: PAGE_SIZE })}`);
|
||||
const body = first.data || {};
|
||||
const rows = body.data || [];
|
||||
const total = Number(body.total);
|
||||
if (!Number.isFinite(total) || rows.length >= total) return rows;
|
||||
|
||||
const pageCount = Math.min(Math.ceil(total / PAGE_SIZE), MAX_PAGES);
|
||||
for (let page = 2; page <= pageCount; page += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const next = await doormileAxios.get(`${path}${buildQuery({ pageno: page, pagesize: PAGE_SIZE })}`);
|
||||
const more = next.data?.data || [];
|
||||
if (!more.length) break;
|
||||
rows.push(...more);
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
// ==============================|| Dashboard, profile, reports ||============================== //
|
||||
|
||||
export const getDashboard = async () => {
|
||||
@@ -162,10 +195,7 @@ export const updateTenantLocation = async (locationId, data) => {
|
||||
|
||||
// ==============================|| Tenant customers (a client's own end customers) ||============================== //
|
||||
|
||||
export const getTenantCustomers = async () => {
|
||||
const response = await doormileAxios.get('/admin/tenantcustomers');
|
||||
return response.data.data;
|
||||
};
|
||||
export const getTenantCustomers = async () => fetchAllPages('/admin/tenantcustomers');
|
||||
|
||||
export const createTenantCustomer = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/tenantcustomers', data);
|
||||
@@ -189,9 +219,21 @@ export const deleteTenantCustomer = async (id) => {
|
||||
|
||||
// ==============================|| B2C app customers ||============================== //
|
||||
|
||||
export const getAdminCustomers = async () => {
|
||||
const response = await doormileAxios.get('/admin/customers');
|
||||
return response.data.data;
|
||||
export const getAdminCustomers = async () => fetchAllPages('/admin/customers');
|
||||
|
||||
// POST /admin/customers.
|
||||
//
|
||||
// express-console-api.md lists this resource as GET + PATCH only, and states
|
||||
// that a customer there comes into existence through a booking. Added on
|
||||
// explicit instruction to create into the store the Customers page actually
|
||||
// reads. If the route genuinely does not exist the call returns 404/405, and
|
||||
// the assistant surfaces that verbatim with the endpoint name rather than
|
||||
// failing silently — so an unsupported route is immediately visible.
|
||||
//
|
||||
// Body mirrors this resource's own GET shape.
|
||||
export const createAdminCustomer = async (data) => {
|
||||
const response = await doormileAxios.post('/admin/customers', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateAdminCustomer = async (id, data) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { MdOutlineAdminPanelSettings, MdPerson, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
@@ -39,6 +40,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
@@ -205,20 +207,25 @@ const AppUsers = () => {
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'first_name',
|
||||
size: 200,
|
||||
minSize: 150,
|
||||
meta: { label: 'Name' },
|
||||
header: () => <SectionLabel>Name</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
accessorKey: 'email',
|
||||
size: 220,
|
||||
minSize: 160,
|
||||
meta: { label: 'Email' },
|
||||
header: () => <SectionLabel>Email</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue()
|
||||
size: 260,
|
||||
minSize: 200,
|
||||
meta: { label: 'User' },
|
||||
header: () => <SectionLabel>User</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<AccentAvatar color={BRAND} size={28}>
|
||||
<MdPerson size={14} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
|
||||
{row.original.first_name} {row.original.last_name || ''}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
|
||||
{row.original.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
@@ -227,16 +234,20 @@ const AppUsers = () => {
|
||||
minSize: 110,
|
||||
meta: { label: 'Phone' },
|
||||
header: () => <SectionLabel>Phone</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
cell: ({ getValue }) => (
|
||||
<Text type="supporting" weight="medium" style={{ fontSize: 12 }}>
|
||||
{getValue() || '—'}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
accessorFn: (row) => roleLabel(row.role),
|
||||
size: 130,
|
||||
minSize: 100,
|
||||
size: 140,
|
||||
minSize: 110,
|
||||
meta: { label: 'Role' },
|
||||
header: () => <SectionLabel>Role</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue()
|
||||
cell: ({ getValue }) => <Badge variant="neutral" label={getValue()} />
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
@@ -245,7 +256,7 @@ const AppUsers = () => {
|
||||
minSize: 100,
|
||||
meta: { label: 'Status' },
|
||||
header: () => <SectionLabel>Status</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
cell: ({ getValue }) => <StatusBadge status={getValue()} />
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -312,8 +323,20 @@ const AppUsers = () => {
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} />
|
||||
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
|
||||
<StatCard
|
||||
title="Total Staff"
|
||||
value={stats.total}
|
||||
meta={`${stats.total} accounts`}
|
||||
icon={<MdOutlineAdminPanelSettings size={20} />}
|
||||
color={BRAND}
|
||||
/>
|
||||
<StatCard
|
||||
title="Admins"
|
||||
value={stats.admins}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.admins / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdOutlineAdminPanelSettings size={20} />}
|
||||
color="#6366f1"
|
||||
/>
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard
|
||||
|
||||
@@ -16,6 +16,8 @@ An in-console Q&A assistant that answers operator questions about live data —
|
||||
- `AIWelcome.js` — greeting + suggestion cards (empty-thread state only).
|
||||
- `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT.
|
||||
- `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline.
|
||||
- `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5).
|
||||
- `AIBulkOrderForm.js` — the one create that stays a form (CSV paste).
|
||||
- `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock.
|
||||
- `pageContext.js` — route → context label + suggested questions.
|
||||
- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`).
|
||||
@@ -25,7 +27,10 @@ An in-console Q&A assistant that answers operator questions about live data —
|
||||
- **Assistant turns must not become bubbles.** The no-bubble treatment is what keeps this reading as part of the dashboard rather than a bolted-on chatbot.
|
||||
- **Never put a React element in message state.** Messages are JSON round-tripped through `localStorage`; elements don't survive it (`$$typeof` is a Symbol and is dropped) and the rehydrated value crashes the next render. Icons are referenced by *key* (`iconKey`) and resolved in `AIParts.js`. Same rule for anything new you add to a message.
|
||||
- **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element.
|
||||
- **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could.
|
||||
- **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components.
|
||||
- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be.
|
||||
- **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too.
|
||||
- **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding.
|
||||
|
||||
---
|
||||
@@ -36,7 +41,23 @@ This was a deliberate, explicit product decision (not a technical limitation wor
|
||||
|
||||
Two paths existed: add a new endpoint to `api.doormile.com` to hold the key (rejected — "don't need to create the new endpoints, use the existing ones"), or stay fully client-side with a much richer deterministic matcher (chosen). **Do not silently reach for an LLM/RAG library here** without first getting a decision on where its key would live — that conversation already happened once and the answer was no.
|
||||
|
||||
RAG (retrieval-augmented generation over a document/vector store) was also explicitly considered and rejected as the wrong tool: this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions. The correct "grounded answer" pattern for that is what's already here — a fixed catalog of `{match, run}` pairs, not a vector search.
|
||||
### RAG — rejected for the data, later built for the ROUTING
|
||||
|
||||
RAG was first considered and rejected, and half that reasoning still stands: **this bot's data isn't unstructured documents**, it's structured operational data reachable through typed API functions. A vector store is a snapshot; "how many orders today" changes by the minute. **No operational data is ever embedded, and no figure ever comes from retrieval.**
|
||||
|
||||
What was later built (`services/ai/`, `ragRouter.js`) applies retrieval to a different problem — *which question is this?* The regex catalog's weakness was never logic, it was vocabulary: "cancellation" not matching `cancel(led)?`, a bare reply matching nothing, "per day" being silently dropped. Retrieval fixes matching without touching how an answer is produced:
|
||||
|
||||
```
|
||||
question → embed → Chroma → intentId + confidence → the SAME run() → live API call
|
||||
```
|
||||
|
||||
Why this does not violate the key constraint above: the embedding model (`Xenova/all-MiniLM-L6-v2`) runs **in-process in Node with no API key**. The blocker was "a hosted model needs a secret and we have nowhere to put it" — that doesn't apply. Moving to hosted embeddings, or adding a generation step, re-opens this section and needs its own decision. `/ask` therefore returns documentation passages **verbatim with attribution**, never a paraphrase.
|
||||
|
||||
Three rules that must hold:
|
||||
|
||||
- **The deterministic matcher stays.** It is the fallback when the sidecar is absent, slow, or unsure. `REACT_APP_AI_URL` unset is a supported state — that is what keeps the app deployable exactly as it is today.
|
||||
- **Slots stay deterministic.** Retrieval picks the intent; `rangeFromWords`/`statusFromWords`/entity resolution still extract the values.
|
||||
- **Write intents need high confidence.** A semantic near-miss must never open a create form.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,6 +102,8 @@ A few layers sit on top of the plain `{match, run}` loop, all in `intents.js`, a
|
||||
- **Comparisons** — `comparisonIntent` (trigger: "vs"/"versus"/"compare[d] to") runs two `fetchBookingsInRange` calls and reports both counts/totals side by side. Ordered early (right after `tenantList`) since it must win before `totalOrders`/`revenueTotal` would otherwise swallow the question on the bare word "orders"/"revenue".
|
||||
- **Multi-part answers** — `answerMultiPart()` splits on and/,/&, matches each segment independently through the same `INTENTS`, and only combines them if ≥2 segments resolve. A single-segment match falls through to the normal path untouched.
|
||||
- **Follow-up context** — `answerQuestion(text, context)` takes `{ lastIntentId, lastParams }` from the previous turn (tracked in `AIPanel.js`'s state). If the new text is a bare date/range phrase ("what about yesterday?") with no other domain keyword, it re-runs the *same* intent with the date swapped rather than requiring the whole question again. This is pattern-matching on the phrase shape, not real conversational memory — a question that also names a different domain is treated as new, not a follow-up.
|
||||
- **`GET /admin/bookings/:id/track` is not called.** Its response shape was never confirmed (`express-console-api.md` lists it as written-but-unproven), so it produced a "Tracking" line nobody could rely on and an audit entry that reported an *error* on every order that simply has no trail yet. Removed on explicit direction — don't add it back without a confirmed response shape. `ROADMAP.md` still proposes it; that entry is stale.
|
||||
- **A pasted booking number is a whole question.** `orderLookup` matches a STRONG reference (`DM-…`, `#1234`) with no keyword around it and answers with the full record — status, rider, recipient, both addresses, service and price, parcels, timestamps, SLA, tracking. A WEAK reference (bare digits) still needs an order/booking/status/where word, or a stray "42" would be read as an order id. Rows are omitted rather than shown as "—", so a blank never reads as "we checked and it's empty" when it means the field isn't on the booking at all.
|
||||
- **Entity lookups** — `riderLookup`/`hubLookup`/`vehicleLookup` require an explicit `LOOKUP_TRIGGER` phrase ("find"/"where is"/"status of"/"search for") before a name, and are ordered ahead of their aggregate counterparts (`riderCounts`/`hubStatus`/`vehicleStatus`) so a named-entity question doesn't get swallowed by the count intent.
|
||||
|
||||
### Ordering and cross-domain guards — read before adding an intent
|
||||
@@ -112,9 +135,140 @@ If you add a new intent whose trigger words could plausibly appear in an unrelat
|
||||
|
||||
---
|
||||
|
||||
### Customer creation writes to `/admin/tenantcustomers`
|
||||
|
||||
Settled by evidence, not by reading the docs:
|
||||
|
||||
```
|
||||
POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
```
|
||||
|
||||
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.**
|
||||
|
||||
**The consequence, which the assistant states in its success message:** a customer created by the bot does **not** appear on the Customers page, because that page reads `GET /admin/customers`. On that resource a customer comes into existence as a side effect of a booking — `POST /admin/expressbooking` documents `customer_phone` as *"creates a Guest customer if unknown"*. A B2C customer is, by design, someone who has ordered. (That is also why `address`/`city`/`latitude` are empty on every live record there.)
|
||||
|
||||
**Resolved:** the **Customers page now reads `GET /admin/tenantcustomers`** (`customers/customers.js`), so a created customer appears there immediately.
|
||||
|
||||
Its **edit dialog moved with it** — `updateTenantCustomer`, not `updateAdminCustomer`. That part is load-bearing: the two stores have separate id sequences, so PATCHing `/admin/customers/:id` with a tenant-customer id is a 404 at best and **edits a different person** at worst. If you ever repoint the read, repoint the write in the same change.
|
||||
|
||||
The page's accessors read **both** record shapes (`name` or `firstname`+`lastname`, `phone` or `contactno`, four possible id fields) because the tenant-customer response shape has never been captured. A field-name difference costs one column, not a table of blanks.
|
||||
|
||||
Creating the customer *via a booking* was rejected: "add a customer" must never silently dispatch a delivery.
|
||||
|
||||
The sidebar's **Create Customer page** (`clients/createCustomer.js`) uses the same endpoint, so page and bot agree.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Conversational writes — `customerFlow.js` / `orderFlow.js`
|
||||
|
||||
Three creates exist: **customer**, **single order**, **bulk orders**. **All three are conversations**, one question per turn — explicit product direction, twice: a form was built first for the customer and replaced, then again for bulk ("don't show it as the form way, it should be like chatting"). There is no create-form component left in this folder; `AICustomerForm`, `AIOrderForm` and `AIBulkOrderForm` were deleted as they became unreachable.
|
||||
|
||||
**The write gate is unchanged and non-negotiable:** the bot gathers, then shows exactly what will be sent, and the mutation fires only when the operator presses the button. `executeCreateCustomer` / `executeCreateOrder` / `executeCreateBulk` are the *only* mutating functions, and nothing calls them from a `match`.
|
||||
|
||||
### The panel drives the conversation, not the router
|
||||
|
||||
`AIPanel.js` intercepts a reply **before `answerQuestion` sees it** whenever a flow is open. This is load-bearing, not a refactor: `answerQuestion` routes by matching text, and a bare answer like `8494948494` matches no intent — the first version of this lost every reply to "I can't answer that one yet." A flow reply must never reach the router.
|
||||
|
||||
Flow state lives in `useState` and is **never persisted**. A half-finished create can't be resurrected in a later session, and `loadHistory` strips `flowStep` on load — a step's `options`/`apply`/`validate` are functions, which JSON drops, so a restored dropdown would render an empty list with nowhere to send an answer.
|
||||
|
||||
### One engine, three flows
|
||||
|
||||
The step-walker is `flowEngine.js`, shared by `orderFlow.js` and `bulkFlow.js`. It was written inside orderFlow and extracted when bulk became a conversation — a second copy would have been a third definition of the same branching rules. `customerFlow.js` predates it and still has its own simpler walker.
|
||||
|
||||
Step entries carry:
|
||||
|
||||
| key | meaning |
|
||||
|---|---|
|
||||
| `type: 'select'` | rendered as an Astryx `Selector` by `AIFlowStep.js`. **Use this wherever the Create Order page uses a dropdown** — asking an operator to type a location name invites one the resolver can't match. |
|
||||
| `type: 'rows'` | rendered as `AIRowsStep.js` — file upload *and* paste in one turn. Offering them together is deliberate: a "file or paste?" question costs a turn and answers nothing the operator hasn't already decided by having a file or not. |
|
||||
| `type: 'text'` | answered through the composer. |
|
||||
| `when(draft)` | skipped when false. This is the branching mechanism (existing vs new customer). |
|
||||
| `options(draft)` | async — locations, customers and tenants are fetched live so a list is never stale or invented. `AIFlowStep` distinguishes loading / empty / failed rather than merging them into one spinner. |
|
||||
| `validate(raw, option)` | re-asks the same step. Gets the chosen **option**, so a select can reject a record (CityGate on a pickup location) and not just a string. |
|
||||
| `resolve(raw)` | may fail and re-ask — geocoding. A delivery with no coordinates can never be dispatched, so it's refused here rather than stored. |
|
||||
| `auto(draft)` | the step answers itself from real data and is only *asked* when that fails, with the reason. Currently just `finalprice`. |
|
||||
|
||||
### Two silent-NaN traps that were live
|
||||
|
||||
- **`tenantid`.** A client login skips the tenant question, but `buildOrderPayload` does `Number(d.tenantid)`. `startOrderFlow` therefore **seeds the draft** from `localStorage.tenantid`. Skipping a question is only safe if something else supplies the value.
|
||||
- **`finalprice`.** Pricing used to happen in the panel after the flow finished, so a tenant with no pricing row produced `finalprice: NaN`. It is now a real step with `auto`: quoted from that tenant's pricing row and the routed distance where possible, **asked for** where not — never zero, never invented. The confirmation says which of the two it was.
|
||||
|
||||
`validateOrderDraft` runs on the whole draft one last time before a Create button is rendered. The per-step checks are for feedback; this is the gate.
|
||||
|
||||
### Bulk — a conversation, then one long pass
|
||||
|
||||
Same opening as the single order, because they are the same questions: tenant → pickup location → service. Only the last step differs: a whole sheet instead of one recipient.
|
||||
|
||||
**Locating and pricing are NOT a step.** They are a pass over the whole file after the last answer, narrated into a single message that rewrites itself (`pushLive` / `patch` in the panel) rather than pushing a turn per row. Making them a step would mean a question nobody is being asked.
|
||||
|
||||
**Stop stops the address lookups, not the pricing.** Nominatim is the ~1/second bottleneck; pricing is unthrottled and bounded by what was already located. Gating pricing on the same flag meant a Stop mid-lookup left every located row unpriced and therefore unsendable — throwing away exactly the work the operator is told is kept.
|
||||
|
||||
**Root cause beats symptom in `validateBulkRow`.** Coordinates are checked before the price: an unlocatable address is *why* the row has no price, and reporting "Price must be a number" for a bad address sends the operator to fix the wrong column.
|
||||
|
||||
### One row pipeline, two inputs
|
||||
|
||||
A file (`bulkFile.js`) and a paste (`parseBulkRows`) produce the **same row array**, so locating, pricing, review, the chunked submit and the per-row report have one implementation. Adding a third input means producing that array, nothing else.
|
||||
|
||||
**The column map is shared with the page.** `utils/bulkOrderColumns.js` holds the map that used to live inside `multipleOrders.js`; the page imports it now. A sheet that uploads on the page uploads in the bot, permanently — copying it was the alternative and is how five pages once ended up with disagreeing `STATUS_META`. `normalizeHeader` is deliberately *not* star-tolerant (the page derives its missing-required warning from the `*`); only the assistant's `rowFieldForHeader` is, because `Receiver Phone*` and `ReceiverPhone` are the same column. That mismatch shipped a template whose own parser couldn't read its phone or address column.
|
||||
|
||||
`Collect Cash` is **not** a price. It is cash to collect from the recipient; `finalprice` is what the delivery costs. Mapping one onto the other bills the wrong number on every row.
|
||||
|
||||
**Locating is the cost, not parsing.** Nominatim allows ~1 lookup/second, so 200 rows is ~3.7 minutes. Three things make that survivable, and none are optional:
|
||||
- Sheets carrying `latitude`/`longitude` columns skip the lookup entirely.
|
||||
- Results are cached by address for the life of the form, so fixing three rows and re-running doesn't re-look-up the other 197.
|
||||
- **Stop is a ref, never state.** It *was* state, read inside the async loop — captured at call time, never updated — so Stop did nothing and the operator waited out every lookup.
|
||||
|
||||
**A blank price means "quote it", never zero.** `priceBulkRows` fetches the tenant's pricing row once for the whole file (per row would be 200 identical requests) and costs one OSRM call per unpriced row. A row that can't be priced keeps its blank price and carries the reason, so it fails validation and is reported rather than being sent at a number nobody chose.
|
||||
|
||||
**There is no idempotency key on `POST /admin/expressbooking/bulk`.** A timed-out submit is therefore unrecoverable by re-sending — it double-books everything that landed. Three guards: duplicates *within* a file are flagged before submit (reported, never auto-removed: two parcels to one door is legitimate); the submitted row-set fingerprint is recorded **before** the request, because a timeout never reaches a success handler; and the failed rows are downloadable so only they get re-uploaded.
|
||||
|
||||
Over-cap files chunk into batches of `BULK_MAX` (200) and report per row regardless of batch. Nothing is ever silently truncated.
|
||||
|
||||
### Repeat Runs — `repeatRuns.js` / `repeatFlow.js`
|
||||
|
||||
"Same orders as yesterday." One question (which day), then a pass, then the usual gate.
|
||||
|
||||
**It is the cheapest write here, and the reason is structural:** a booking already carries 15 of the 17 fields `buildOrderPayload` needs — including BOTH SETS OF COORDINATES. Only `customer_name` and `customer_phone` are missing, and they come from the `appcustomerid` → `/admin/customers` join. **So a repeat needs no geocoding at all** — the ~1 lookup/second Nominatim throttle that dominates the bulk-file flow simply doesn't apply.
|
||||
|
||||
**A booking is a snapshot, not a template.** The drift check is phase one, not polish. All three of its main rules came from one live page of 36 bookings, not from imagination:
|
||||
|
||||
| Trap | Seen on |
|
||||
|---|---|
|
||||
| `pickupaddress` absent entirely | booking 57 — has the pincode and coordinates, no address key |
|
||||
| `tenantid` is null | every `Customer_App` booking (24–27). `Number(null)` → tenant `0` |
|
||||
| pickup pincode no longer served | CityGate refuses at the middleware, before the handler |
|
||||
|
||||
Plus: the customer record can be deleted, and a booking can lack delivery coordinates. `driftReason` returns a **reason, never a boolean** — an operator dropping a row deserves to know which field went stale.
|
||||
|
||||
**Duplicate safety is INVERTED here.** Everywhere else near-identical orders are an error (`wasAlreadySubmitted`); a repeat deliberately creates them, so that guard would misfire every time. The question that matters is *has this run already been repeated today?* — answered by fingerprinting today's own bookings on `(phone + delivery address + pickup pincode)` and setting aside anything already present. Without it, a double-click books every customer twice, because the bulk endpoint has no idempotency key.
|
||||
|
||||
**Prices are re-quoted at today's tariff, never copied.** `finalprice` is deliberately left blank so `priceBulkRows` fills it exactly as an unpriced bulk row. Yesterday's number is kept as `previousPrice` purely so a tariff change is *visible* rather than discovered on an invoice. Pricing runs **per row** because a day's run can span tenants, and a tenant's own pricing row decides the number.
|
||||
|
||||
**`__pickup` travels on the ROW, not the shared draft** — which is why `executeCreateBulk` now prefers `r.__pickup ?? shared.__pickup`. A bulk file shares one kitchen; a repeated day does not, and collapsing them would silently re-address half the orders.
|
||||
|
||||
Cancelled orders are never repeated. Lookback is 7 days — beyond that it stops being "the usual round".
|
||||
|
||||
### Assigning a rider — `assignActions.js` / `assignFlow.js`
|
||||
|
||||
The fourth write. Reached three ways: automatically after a single create, from `"assign a rider to DM-BK-…"`, and offered after a bulk run.
|
||||
|
||||
**Two endpoints, and they are not interchangeable.** One order → `POST /admin/bookings/:id/assign-miler`. Many orders → `POST /hub/bookings/batch-assign`, which is **the only call that sequences stops** (doormile-flow.md §4): it sends each affected rider's whole active set to the route optimiser and writes step order, per-leg distance and ETA. Assigning ten orders with ten single calls leaves every route unsequenced.
|
||||
|
||||
**⚠ Two different rider IDs on adjacent endpoints.** `assign-miler` takes a **`mileruserid`**; `/admin/milers/:id/notify` keys off a **`milerprofileid`**. Getting it wrong fails silently in both directions — the assign 404s, or the rider is never told. `buildMilerLookup` is the bridge and orders.js already uses it for exactly this; don't grow a second lookup. The assertions cover this specifically because it is invisible in review: both are small integers on the same record.
|
||||
|
||||
**The backend already assigns riders.** Creation publishes `booking.assignment_requested`; a worker picks a rider within 10km on proximity and retries 5× over 10 minutes (§3). Everything here is an **override**, which is why the flow re-reads the booking's current assignee and asks before replacing them. Silently overwriting throws away a better-informed choice and strands a rider who has already been told the job is theirs.
|
||||
|
||||
**The holder lookup happens inside the booking step's `resolve`, not after it.** `advanceFlow` evaluates the keep/replace step's `when` the instant the booking is applied — a lookup landing one tick later means the step is skipped and an already-assigned order is silently reassigned. That was a live bug caught by the assertions.
|
||||
|
||||
Notification failure never fails the assignment: the order **is** assigned at that point, and reporting otherwise would be a lie. It is recorded as a failed source call instead. A rider with no `milerprofileid` is stated explicitly rather than letting the operator assume a phone buzzed.
|
||||
|
||||
Assertions for both engines live outside the repo (project convention is lint-only) — 56 for `orderFlow`, 44 for `bulkFlow`, 42 for `assignFlow`, 30 for `repeatRuns`, 20 for `customerFlow`, covering the branching, the geocode re-ask, the CityGate refusal and the unpriceable path.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's deliberately out of scope right now
|
||||
|
||||
- **Write actions.** The original ask included "if I say create an order, it should create it" — deliberately **not built**. Giving a keyword-matched bot the ability to mutate data (order creation has real validation elsewhere: CityGate pincode checks, delivery-slot windows, the dispatch reconcile-before-commit rule) is a materially bigger risk than read-only Q&A. If this gets built, it needs its own guardrail — the bot proposes what it would submit, the operator explicitly confirms, only then does a real create-order call fire. Don't wire a write action straight from intent match to a mutation call.
|
||||
- **Deleting or cancelling anything.** Creates and rider assignment are built (§3.5); destructive writes are not. Cancelling an order has downstream effects a confirm button doesn't cover. Note that *replacing* an already-assigned rider IS reachable — but only behind an explicit keep-or-replace question naming the current holder, never as a silent overwrite.
|
||||
- **Open-ended LLM understanding.** See §2. Revisit only with an explicit decision on where the LLM key lives.
|
||||
- **Tenant/role-aware scoping.** Every intent currently queries the same data an unscoped admin session would see — there's no per-login "you only see your own tenant" filter applied inside `intents.js` itself. Needs a decision on how tenant-locked logins should be detected (`localStorage.tenantid`/`roleid`) and whether that's a hard filter or just a default, before it's built.
|
||||
- **Proactive alerts.** Surfacing anomalies unprompted (e.g. "3 hubs inactive") via the notification bell is a different feature from Q&A — it needs a polling/watch mechanism, and the notification panel it would feed is currently static UI scaffolding, not wired to a real alert stream. Not started.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { LuArrowUp } from 'react-icons/lu';
|
||||
|
||||
@@ -17,11 +17,73 @@ import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
|
||||
|
||||
const MAX_ROWS_PX = 108;
|
||||
|
||||
// How long a dictating operator has to stay quiet before the message sends
|
||||
// itself. Long enough to think mid-sentence, short enough that you are not left
|
||||
// wondering whether it heard you.
|
||||
const SILENCE_MS = 3000;
|
||||
|
||||
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
const textareaRef = useRef(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) });
|
||||
// ---- dictation: send on silence ----
|
||||
// Speech arrives as a stream of partial transcripts, so "finished speaking"
|
||||
// is not an event the API gives us — it has to be inferred from the stream
|
||||
// going quiet. Every transcript update re-arms a timer; whichever one is not
|
||||
// followed by another within SILENCE_MS is the end of the sentence.
|
||||
//
|
||||
// Everything the timer touches is read through a ref. The callback is created
|
||||
// once and fires up to three seconds later, so a closed-over `value` or
|
||||
// `isBusy` would be whatever they were when dictation started, not when the
|
||||
// operator stopped talking — it would send a stale half-sentence, or send
|
||||
// while a previous answer was still streaming.
|
||||
const valueRef = useRef(value);
|
||||
const isBusyRef = useRef(isBusy);
|
||||
const dictationRef = useRef(null);
|
||||
const silenceTimer = useRef(0);
|
||||
|
||||
valueRef.current = value;
|
||||
isBusyRef.current = isBusy;
|
||||
|
||||
const clearSilence = useCallback(() => {
|
||||
if (silenceTimer.current) {
|
||||
clearTimeout(silenceTimer.current);
|
||||
silenceTimer.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const armSilence = useCallback(() => {
|
||||
clearSilence();
|
||||
silenceTimer.current = setTimeout(() => {
|
||||
silenceTimer.current = 0;
|
||||
// Stop the mic first. Sending while it is still listening would let the
|
||||
// next words land in an input the operator has already "sent".
|
||||
dictationRef.current?.stop();
|
||||
const text = valueRef.current.trim();
|
||||
if (text && !isBusyRef.current) onSubmit(text);
|
||||
}, SILENCE_MS);
|
||||
}, [clearSilence, onSubmit]);
|
||||
|
||||
const dictation = useChatDictation({
|
||||
// Fires on every partial AND final result, which is what makes this a
|
||||
// silence detector rather than an end-of-recognition one.
|
||||
onTranscript: (transcript) => {
|
||||
onChange(transcript);
|
||||
armSilence();
|
||||
},
|
||||
onResult: (transcript) => {
|
||||
onChange(transcript);
|
||||
armSilence();
|
||||
},
|
||||
// Stopped by hand, or the engine gave up: no pending auto-send.
|
||||
onEnd: clearSilence,
|
||||
onError: clearSilence
|
||||
});
|
||||
|
||||
dictationRef.current = dictation;
|
||||
|
||||
// A panel closed mid-sentence must not fire a message afterwards.
|
||||
useEffect(() => clearSilence, [clearSilence]);
|
||||
|
||||
// Grow with content, then let the stylesheet's max-height take over and
|
||||
// scroll. Runs before paint so there's no visible jump on the first line
|
||||
@@ -61,8 +123,12 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
placeholder={placeholder}
|
||||
aria-label="Ask Doormile AI about operations"
|
||||
/>
|
||||
<HStack gap={1} padding={0} vAlign="center" justify="between">
|
||||
<ChatDictationButton dictation={dictation} size="sm" />
|
||||
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
|
||||
to the far left and the send button to the far right, leaving a
|
||||
wide dead gap between two things that do the same job — put a
|
||||
message in. Together they read as one action cluster. */}
|
||||
<HStack gap={1} padding={0} vAlign="center" justify="end">
|
||||
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
||||
<button
|
||||
type="button"
|
||||
className="dai-send"
|
||||
@@ -74,7 +140,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
</button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text className="dai-hint">Enter to send · Shift + Enter for a new line</Text>
|
||||
<Text className="dai-hint">
|
||||
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
118
src/pages/nearle/assistant/DoormileAI/AIFlowStep.js
Normal file
118
src/pages/nearle/assistant/DoormileAI/AIFlowStep.js
Normal file
@@ -0,0 +1,118 @@
|
||||
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}>
|
||||
{/* An empty list must still leave a way forward. The generic message
|
||||
plus a lone Cancel is a dead end, and it is reached exactly when
|
||||
something upstream failed — which is the worst moment to strip the
|
||||
operator of options. A step that can also accept a typed answer
|
||||
says so here via `emptyHint`. */}
|
||||
<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.'
|
||||
: step.emptyHint || 'There’s nothing to choose from here.'}
|
||||
</Text>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const chosen = options.find((o) => String(o.value) === String(value));
|
||||
|
||||
return (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
<Selector
|
||||
label={step.ask}
|
||||
isLabelHidden
|
||||
placeholder="Choose one"
|
||||
options={options.map((o) => ({ value: String(o.value), label: o.label }))}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
// A long customer list is unusable without search.
|
||||
hasSearch={options.length > 8}
|
||||
isDisabled={isBusy}
|
||||
/>
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label="Continue"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!value || isBusy}
|
||||
// The whole option is passed back, not just its value — steps that
|
||||
// pick a record (a location, a customer) need the record itself to
|
||||
// fill several fields at once.
|
||||
onClick={() => onChoose(value, chosen)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIFlowStep.propTypes = {
|
||||
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string, options: PropTypes.func }).isRequired,
|
||||
onChoose: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
draft: PropTypes.object,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIFlowStep;
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
|
||||
import { LuChevronDown } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { ChatToolCalls } from '@astryxdesign/core/Chat';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock } from './AIParts';
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
import AIRowsStep from './AIRowsStep';
|
||||
import { failedRowsCsv, downloadCsv } from '../bulkFile';
|
||||
|
||||
// ==============================|| Doormile AI — a single turn ||============================== //
|
||||
//
|
||||
@@ -30,7 +34,7 @@ const UserMessage = ({ text }) => (
|
||||
|
||||
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
||||
|
||||
const AssistantMessage = ({ message, onCopy, onAsk }) => {
|
||||
const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
|
||||
const [showSources, setShowSources] = useState(false);
|
||||
const sourceCount = message.sourceCalls?.length || 0;
|
||||
|
||||
@@ -67,7 +71,14 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
|
||||
|
||||
<StatGrid stats={message.stats} />
|
||||
|
||||
{message.detail && <Text className="dai-msg-ai-detail">{message.detail}</Text>}
|
||||
{message.list && <AnswerList title={message.list.title} items={message.list.items} numbered={message.list.numbered !== false} />}
|
||||
|
||||
{message.detail &&
|
||||
(message.isDoc ? (
|
||||
<Text className="dai-doc">{message.detail}</Text>
|
||||
) : (
|
||||
<Text className="dai-msg-ai-detail">{message.detail}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -75,31 +86,121 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
|
||||
that it's available, not that it's announced on every turn. */}
|
||||
{sourceCount > 0 && (
|
||||
<VStack gap={1} padding={0}>
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<HStack className="dai-msg-foot" gap={0} padding={0}>
|
||||
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
|
||||
<button type="button" className="dai-link" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
<LuChevronDown
|
||||
size={11}
|
||||
style={{ marginLeft: 3, verticalAlign: '-1px', transform: showSources ? 'rotate(180deg)' : 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* The chevron's offset and rotation live in the stylesheet — as inline
|
||||
styles they couldn't be transitioned, and `verticalAlign` only
|
||||
works while the button's content is inline, which is exactly the
|
||||
thing that made it wrap. */}
|
||||
<button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<LuChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{message.followUps?.length > 0 && (
|
||||
<VStack gap={1} padding={0}>
|
||||
{message.followUps.map((q) => (
|
||||
<button key={q} type="button" className="dai-suggestion" onClick={() => onAsk(q)}>
|
||||
<Text className="dai-suggestion-text">{q}</Text>
|
||||
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
{/* ---- Write gate ---------------------------------------------------
|
||||
Real inputs, not a parsed sentence. The form shows every value that
|
||||
will be sent and submits only on Create. A form rehydrated from
|
||||
localStorage in a later session is marked 'stale' by the panel and
|
||||
renders read-only, so a write can never fire from a resurrected
|
||||
message. */}
|
||||
{/* A conversational step that needs a dropdown rather than typing. */}
|
||||
{message.liveStop && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* The bulk create's rows turn — a whole file in one answer. */}
|
||||
{message.flowStep?.type === 'rows' && !message.flowStep.__answered && (
|
||||
<AIRowsStep
|
||||
isBusy={message.flowStep.__answered}
|
||||
onSubmit={(parsed) => onChooseStep(message, parsed)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.flowStep?.type === 'select' && (
|
||||
<AIFlowStep
|
||||
step={message.flowStep}
|
||||
draft={message.flowDraft}
|
||||
isBusy={message.flowStep.__answered}
|
||||
onChoose={(value, option) => onChooseStep(message, value, option)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign'].includes(message.form?.kind) && (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{message.form.status === 'open' ? (
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={
|
||||
message.form.confirmLabel
|
||||
? message.form.confirmLabel
|
||||
: message.form.kind === 'confirmBulk'
|
||||
? `Create ${message.form.draft?.rows?.length || 0} orders`
|
||||
: message.form.kind === 'confirmOrder'
|
||||
? 'Create order'
|
||||
: message.form.kind === 'confirmAssign'
|
||||
? 'Assign rider'
|
||||
: 'Submit'
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
|
||||
{/* A second, wider commit — used when rows were excluded by
|
||||
default but including them is a legitimate choice. */}
|
||||
{message.form.altDraft && (
|
||||
<Button
|
||||
label={message.form.altLabel || 'Include everything'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.altDraft)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Follow-ups are NOT rendered here.
|
||||
|
||||
They used to appear twice: once under each answer and again in the
|
||||
strip above the composer. Two sets of chips offering overlapping
|
||||
questions in one panel is the assistant asking the same thing twice,
|
||||
and the in-message copy scrolled away the moment the next answer
|
||||
arrived — so the one that was always reachable is the one that was
|
||||
kept. `message.followUps` still carries the intent's suggestions;
|
||||
AIPanel feeds them into that single strip. */}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -107,16 +208,38 @@ const AssistantMessage = ({ message, onCopy, onAsk }) => {
|
||||
AssistantMessage.propTypes = {
|
||||
message: PropTypes.object.isRequired,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
onSubmitForm: PropTypes.func.isRequired,
|
||||
onCancelAction: PropTypes.func.isRequired,
|
||||
onChooseStep: PropTypes.func.isRequired,
|
||||
onStopLive: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIMessage = ({ message, onCopy, onAsk }) =>
|
||||
message.sender === 'user' ? <UserMessage text={message.text} /> : <AssistantMessage message={message} onCopy={onCopy} onAsk={onAsk} />;
|
||||
const AIMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
|
||||
message.sender === 'user' ? (
|
||||
<UserMessage text={message.text} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
onCopy={onCopy}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onCancelAction={onCancelAction}
|
||||
onChooseStep={onChooseStep}
|
||||
onStopLive={onStopLive}
|
||||
/>
|
||||
);
|
||||
|
||||
AIMessage.propTypes = {
|
||||
message: PropTypes.object.isRequired,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
onSubmitForm: 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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,25 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { LuSparkles } from 'react-icons/lu';
|
||||
import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
|
||||
// ==============================|| Doormile AI — shared primitives ||============================== //
|
||||
//
|
||||
// Small presentational pieces shared by the panel's states. Kept in one file
|
||||
// because each is a handful of lines and they are only ever used together.
|
||||
|
||||
// The AI mark — a soft gradient orb, deliberately not a robot face.
|
||||
// The Doormile D. One component behind the assistant's identity, so the
|
||||
// header, every reply, the welcome screen and the thinking state can't drift
|
||||
// apart — they all render this. The mark is RGBA with a transparent ground, so
|
||||
// it sits on the panel surface rather than needing a coloured chip behind it.
|
||||
export const Spark = ({ size = 'sm' }) => (
|
||||
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
|
||||
<LuSparkles size={size === 'lg' ? 20 : size === 'md' ? 15 : 12} strokeWidth={2.1} />
|
||||
<img src={doormileMark} alt="" />
|
||||
</HStack>
|
||||
);
|
||||
|
||||
@@ -98,6 +103,38 @@ const STATE_ICONS = {
|
||||
info: <InfoCircleOutlined />
|
||||
};
|
||||
|
||||
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
|
||||
// left an operator unable to see the rest of an answer at all — the body
|
||||
// scrolls past ~12 rows so a long list can't stretch the panel.
|
||||
export const AnswerList = ({ title, items, numbered = true }) => {
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<VStack className="dai-list" gap={0} padding={0}>
|
||||
<HStack className="dai-list-head" padding={0} gap={0}>
|
||||
<span>{title}</span>
|
||||
<span>{items.length}</span>
|
||||
</HStack>
|
||||
<VStack className="dai-list-body" gap={0} padding={0} role="list">
|
||||
{items.map((item, i) => (
|
||||
<HStack className="dai-list-item" key={`${item.label}-${i}`} padding={0} gap={0} role="listitem">
|
||||
{/* Numbering makes sense for "the 14 tenants"; it is noise on a
|
||||
field/value dump like a submitted payload. */}
|
||||
{numbered ? <span className="dai-list-index">{i + 1}</span> : null}
|
||||
<span className="dai-list-label">{item.label}</span>
|
||||
{item.meta ? <span className="dai-list-meta">{item.meta}</span> : null}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AnswerList.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string.isRequired, meta: PropTypes.string })),
|
||||
numbered: PropTypes.bool
|
||||
};
|
||||
|
||||
// Shared shell for the error / no-data states, so both read as the same kind
|
||||
// of thing rather than two separately-invented blocks.
|
||||
export const StateBlock = ({ iconKey, tone, title, body, action }) => (
|
||||
|
||||
154
src/pages/nearle/assistant/DoormileAI/AIRowsStep.js
Normal file
154
src/pages/nearle/assistant/DoormileAI/AIRowsStep.js
Normal file
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Keyed by line AND index: one row can fail several validations, so
|
||||
`line` alone is not unique and React logged "two children with the
|
||||
same key" for every repeat — seen live as a 1/2/3 cycle. Same
|
||||
shape the list renderer in AIParts already uses. */}
|
||||
{parsed.errors.slice(0, 5).map((e, i) => (
|
||||
<Text key={`${e.line}-${i}`} className="dai-field-err">
|
||||
Line {e.line}: {e.reason}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <Text className="dai-action-note">…and {parsed.errors.length - 5} more unusable rows.</Text>}
|
||||
|
||||
{parsed.rows.length > BULK_MAX && (
|
||||
<Text className="dai-action-note">
|
||||
Over {BULK_MAX} — I’ll send them in {Math.ceil(parsed.rows.length / BULK_MAX)} batches.
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={parsed?.rows.length ? `Use these ${parsed.rows.length} rows` : 'Continue'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={!parsed?.rows.length || isBusy || isReading}
|
||||
onClick={() => onSubmit(parsed)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
|
||||
<Button
|
||||
label="Template"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
tooltip="Download a blank sheet with the exact column titles I read"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-template.csv', templateCsv())}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text className="dai-action-note">
|
||||
Pasted rows: name, phone, address, pincode, city, price, description — comma, tab or pipe separated. Leave the price blank and I’ll
|
||||
quote it.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIRowsStep.propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
isBusy: PropTypes.bool
|
||||
};
|
||||
|
||||
export default AIRowsStep;
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import dayjs from 'dayjs';
|
||||
import { LuArrowRight } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import { Spark } from './AIParts';
|
||||
import { CHIP_LABELS } from './pageContext';
|
||||
|
||||
// ==============================|| Doormile AI — welcome state ||============================== //
|
||||
//
|
||||
@@ -22,65 +20,71 @@ const greeting = () => {
|
||||
return 'Good evening';
|
||||
};
|
||||
|
||||
const SuggestionCard = ({ item, onAsk }) => {
|
||||
// A chip, not a row.
|
||||
//
|
||||
// These used to be full-width rows: a 26px icon tile, the question, and a
|
||||
// reveal-on-hover arrow, stacked one per line. That reads fine for four
|
||||
// suggestions and falls apart at twenty — the panel opened on a wall of
|
||||
// identical boxes that had to be read top to bottom, and the whole welcome
|
||||
// state scrolled.
|
||||
//
|
||||
// As chips they flow into a block that is scanned rather than read: each one
|
||||
// is only as wide as its question, so the varying widths give the eye
|
||||
// something to land on, and twenty of them occupy a few rows instead of
|
||||
// twenty. The icon stays as a small inline glyph for recognition; the arrow
|
||||
// is gone, because on a chip it was decoration competing with the label.
|
||||
const SuggestionChip = ({ item, onAsk }) => {
|
||||
const Icon = item.icon;
|
||||
// Display short, ask long. `item.text` is what intents.js matches on, so it
|
||||
// is what gets asked and what assistive tech announces; the chip only shows
|
||||
// a trimmed version of it so twenty of them fit in a few rows.
|
||||
const label = CHIP_LABELS[item.text] || item.text;
|
||||
return (
|
||||
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)}>
|
||||
<HStack className="dai-suggestion-icon" padding={0} gap={0} aria-hidden="true">
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
</HStack>
|
||||
<Text className="dai-suggestion-text">{item.text}</Text>
|
||||
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
|
||||
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)} aria-label={item.text} title={item.text}>
|
||||
<Icon className="dai-suggestion-icon" size={13} strokeWidth={2} aria-hidden="true" />
|
||||
<span className="dai-suggestion-text">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
SuggestionCard.propTypes = {
|
||||
SuggestionChip.propTypes = {
|
||||
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIWelcome = ({ context, onAsk }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const more = context.more || [];
|
||||
const shown = expanded ? [...context.suggestions, ...more] : context.suggestions;
|
||||
// Exported so AIPanel can render it directly above the composer rather than
|
||||
// inside the scrolling body. The chips are a way to START a message, so they
|
||||
// belong with the input, not buried in the welcome copy where they scrolled
|
||||
// out of reach the moment a conversation began.
|
||||
export const SuggestionChips = ({ items, onAsk }) => (
|
||||
<HStack className="dai-suggestions" wrap="wrap" gap={1.5} padding={0} vAlign="start">
|
||||
{items.map((item) => (
|
||||
<SuggestionChip key={item.text} item={item} onAsk={onAsk} />
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
SuggestionChips.propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const AIWelcome = () => {
|
||||
return (
|
||||
<VStack className="dai-welcome" gap={3} padding={0}>
|
||||
<VStack gap={1.5} padding={0}>
|
||||
<Spark size="lg" />
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||
</VStack>
|
||||
<Text className="dai-welcome-note">
|
||||
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5} padding={0}>
|
||||
<Text className="dai-section-label">Suggested questions</Text>
|
||||
<VStack gap={1} padding={0}>
|
||||
{shown.map((item) => (
|
||||
<SuggestionCard key={item.text} item={item} onAsk={onAsk} />
|
||||
))}
|
||||
</VStack>
|
||||
{more.length > 0 && (
|
||||
<button type="button" className="dai-link" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded}>
|
||||
{expanded ? 'Show fewer' : `View ${more.length} more`}
|
||||
</button>
|
||||
)}
|
||||
<VStack className="dai-welcome" gap={1.5} padding={0}>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||
</VStack>
|
||||
<Text className="dai-welcome-note">
|
||||
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
AIWelcome.propTypes = {
|
||||
context: PropTypes.shape({
|
||||
suggestions: PropTypes.array.isRequired,
|
||||
more: PropTypes.array
|
||||
}).isRequired,
|
||||
onAsk: PropTypes.func.isRequired
|
||||
onAsk: PropTypes.func
|
||||
};
|
||||
|
||||
export default AIWelcome;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { LuSparkles } from 'react-icons/lu';
|
||||
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
|
||||
import doormileMark from 'assets/images/doormile-mark.png';
|
||||
import AIPanel from './AIPanel';
|
||||
import '../DoormileAI.css';
|
||||
|
||||
@@ -33,7 +33,7 @@ const DoormileAITrigger = () => {
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
<LuSparkles size={16} strokeWidth={2.1} aria-hidden="true" />
|
||||
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<AIPanel isOpen={isOpen} onClose={close} />
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { LuClock3, LuPackage, LuBike, LuTruck, LuBuilding2, LuCircleDot, LuBanknote, LuUsers, LuLayers, LuTimerOff } from 'react-icons/lu';
|
||||
import {
|
||||
LuClock3,
|
||||
LuPackage,
|
||||
LuBike,
|
||||
LuTruck,
|
||||
LuBuilding2,
|
||||
LuCircleDot,
|
||||
LuBanknote,
|
||||
LuUsers,
|
||||
LuLayers,
|
||||
LuTimerOff,
|
||||
LuUserPlus,
|
||||
LuPackagePlus,
|
||||
LuListPlus,
|
||||
LuRepeat
|
||||
} from 'react-icons/lu';
|
||||
|
||||
// ==============================|| Doormile AI — page context ||============================== //
|
||||
//
|
||||
@@ -12,15 +27,88 @@ import { LuClock3, LuPackage, LuBike, LuTruck, LuBuilding2, LuCircleDot, LuBankn
|
||||
// answer that yet" is worse than no chip. When adding one, check it against
|
||||
// the INTENTS catalog first.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Short chip labels.
|
||||
//
|
||||
// Every `text` below is the exact phrasing the deterministic matcher in
|
||||
// intents.js resolves, so it CANNOT be shortened in place — that is the string
|
||||
// the click actually asks. This map only changes what the chip DISPLAYS.
|
||||
//
|
||||
// It exists because the welcome state offers up to twenty suggestions, and as
|
||||
// full sentences they are 130-255px wide in a ~393px panel: barely two fit per
|
||||
// row, so the "chips" laid out as fourteen near-full-width rows and read as a
|
||||
// list again. Shortened, three or four fit per row and the block becomes
|
||||
// something you scan.
|
||||
//
|
||||
// The full question stays the accessible name and the tooltip, so nothing is
|
||||
// lost for screen readers or for anyone unsure what a chip will ask. A
|
||||
// suggestion with no entry here simply shows its full text.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CHIP_LABELS = {
|
||||
"Give me today's operations summary": "Today's summary",
|
||||
'How many orders today?': 'Orders today',
|
||||
'Which orders are delayed?': 'Delayed orders',
|
||||
'Create an order': 'Create order',
|
||||
'Create multiple orders': 'Bulk orders',
|
||||
'Total revenue today': 'Revenue today',
|
||||
'How many pending orders today?': 'Pending today',
|
||||
'How many cancelled orders today?': 'Cancelled today',
|
||||
'How many delivered orders today?': 'Delivered today',
|
||||
'Morning Batch orders today': 'Morning batch',
|
||||
'Orders today vs yesterday': 'Today vs yesterday',
|
||||
'Create a customer': 'Create customer',
|
||||
'How many riders are active?': 'Active riders',
|
||||
'How many vehicles are available?': 'Vehicles available',
|
||||
'Current hub status': 'Hub status',
|
||||
'Which hubs are experiencing delays?': 'Hub delays',
|
||||
'How many tenants do we have?': 'Tenants',
|
||||
'How many orders this week?': 'Orders this week',
|
||||
'Total revenue this week': 'Revenue this week',
|
||||
'How many customers do we have?': 'Customers',
|
||||
'Assign a rider': 'Assign order',
|
||||
"Repeat yesterday's orders": 'Repeat yesterday'
|
||||
};
|
||||
|
||||
// The one follow-up phrasing that is not already a page suggestion. Verified
|
||||
// against ASSIGN_TRIGGER in assignActions.js — it opens the assign-rider flow.
|
||||
const ASSIGN_TEXT = 'Assign a rider';
|
||||
|
||||
// Recreates a previous day's run rather than building an order from scratch.
|
||||
// This is the phrasing intents.js documents for it ("repeat yesterday's
|
||||
// orders") and it is matched by REPEAT_TRIGGER in repeatRuns.js, which is
|
||||
// ordered AHEAD of createOrder/createBulkOrders precisely so a sentence
|
||||
// containing both "repeat" and "orders" recalls the run instead of opening a
|
||||
// blank create form.
|
||||
const REPEAT_TEXT = "Repeat yesterday's orders";
|
||||
|
||||
// Not a question — an OUTCOME. AIPanel sets this as the follow-up key when a
|
||||
// create or repeat run actually succeeds, so the next steps offered are the
|
||||
// ones that follow from an order existing, rather than the ones that follow
|
||||
// from having asked to make one. Assigning a rider only makes sense on this
|
||||
// side of the write.
|
||||
export const ORDER_CREATED = '__orderCreated';
|
||||
|
||||
// The same write, but with every new order already given a rider — a repeat run
|
||||
// that handed each copy back to whoever ran it yesterday.
|
||||
//
|
||||
// It needs its own key purely so ASSIGN_TEXT can be left out. Offering "Assign
|
||||
// order" after the bot has just assigned them all presents finished work as a
|
||||
// pending step, and an operator who takes the suggestion is walked into
|
||||
// reassigning orders that are already out.
|
||||
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
|
||||
|
||||
const ORDERS = {
|
||||
label: 'Orders',
|
||||
suggestions: [
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' },
|
||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' }
|
||||
{ icon: LuPackagePlus, text: 'Create an order' },
|
||||
{ icon: LuRepeat, text: REPEAT_TEXT }
|
||||
],
|
||||
more: [
|
||||
{ icon: LuListPlus, text: 'Create multiple orders' },
|
||||
{ icon: LuBanknote, text: 'Total revenue today' },
|
||||
{ icon: LuCircleDot, text: 'How many pending orders today?' },
|
||||
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||
@@ -115,6 +203,81 @@ const DEFAULT_CONTEXT = {
|
||||
]
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Follow-ups.
|
||||
//
|
||||
// What the suggestion strip offers AFTER a question has been asked. Showing
|
||||
// all twenty again is noise — the operator has already told you what they are
|
||||
// doing, so the next set should be the natural next steps for it. Creating an
|
||||
// order leads to assigning a rider and to what is still pending; asking about
|
||||
// revenue leads to the week and to the day-over-day comparison.
|
||||
//
|
||||
// Every string here is either one of the CHIP_LABELS questions above (all of
|
||||
// which the matcher already resolves) or ASSIGN_TEXT. That constraint is the
|
||||
// whole point: pageContext's rule is that a suggestion which returns "I can't
|
||||
// answer that yet" is worse than no suggestion, and a follow-up is MORE likely
|
||||
// to be clicked than a cold one, because it arrives exactly when it is
|
||||
// relevant. Do not add a phrasing here without checking it against INTENTS.
|
||||
// ---------------------------------------------------------------------------
|
||||
const FOLLOW_UPS = {
|
||||
// --- write actions ---
|
||||
// Note what is NOT here: assigning a rider.
|
||||
//
|
||||
// "Create an order" only STARTS the create conversation — no order exists at
|
||||
// that moment, so offering to assign one is offering to act on nothing. And
|
||||
// once the order really is created, AIPanel already drops the operator
|
||||
// straight into the assign flow itself (see the confirmOrder branch), because
|
||||
// that hand-off is mandatory rather than optional. A chip there would either
|
||||
// duplicate a step the product performs on its own or fire it too early.
|
||||
//
|
||||
// ASSIGN_TEXT belongs on the questions below that surface orders which
|
||||
// already EXIST and are waiting for a rider — and on ORDER_CREATED, which is
|
||||
// keyed off the write succeeding rather than off anything that was asked.
|
||||
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
|
||||
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
|
||||
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
|
||||
'Create a customer': ['Create an order', 'How many customers do we have?'],
|
||||
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
|
||||
[ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
||||
// No ASSIGN_TEXT: there is nothing left to assign.
|
||||
[ORDER_CREATED_ASSIGNED]: ['How many riders are active?', 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
|
||||
|
||||
// --- order volume ---
|
||||
"Give me today's operations summary": [
|
||||
'How many pending orders today?',
|
||||
'Which orders are delayed?',
|
||||
'Total revenue today',
|
||||
'How many riders are active?'
|
||||
],
|
||||
'How many orders today?': [
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today'
|
||||
],
|
||||
'How many pending orders today?': [ASSIGN_TEXT, 'Which orders are delayed?', 'How many delivered orders today?'],
|
||||
'How many delivered orders today?': ['How many pending orders today?', 'Total revenue today', 'Orders today vs yesterday'],
|
||||
'How many cancelled orders today?': ['How many delivered orders today?', 'How many orders today?', 'Which orders are delayed?'],
|
||||
'Which orders are delayed?': [ASSIGN_TEXT, 'Which hubs are experiencing delays?', 'How many riders are active?'],
|
||||
'Morning Batch orders today': ['Which orders are delayed?', 'How many riders are active?', 'How many orders today?'],
|
||||
|
||||
// --- money and trend ---
|
||||
'Total revenue today': ['Total revenue this week', 'Orders today vs yesterday', 'How many orders today?'],
|
||||
'Total revenue this week': ['How many orders this week?', 'Total revenue today'],
|
||||
'Orders today vs yesterday': [REPEAT_TEXT, 'Total revenue today', 'How many orders this week?'],
|
||||
'How many orders this week?': ['Total revenue this week', 'How many orders today?'],
|
||||
|
||||
// --- fleet and network ---
|
||||
'How many riders are active?': ['How many vehicles are available?', 'Current hub status', ASSIGN_TEXT],
|
||||
'How many vehicles are available?': ['How many riders are active?', 'Current hub status'],
|
||||
'Current hub status': ['Which hubs are experiencing delays?', 'How many vehicles are available?', 'How many riders are active?'],
|
||||
'Which hubs are experiencing delays?': ['Current hub status', 'Which orders are delayed?', 'How many riders are active?'],
|
||||
|
||||
// --- accounts ---
|
||||
'How many tenants do we have?': ['How many customers do we have?', 'How many orders today?'],
|
||||
'How many customers do we have?': ['Create a customer', 'How many tenants do we have?']
|
||||
};
|
||||
|
||||
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
||||
// with the wrong label.
|
||||
const ROUTES = [
|
||||
@@ -126,13 +289,125 @@ const ROUTES = [
|
||||
['/doormile/hubs', HUBS],
|
||||
['/doormile/tenants', TENANTS],
|
||||
['/doormile/reports', REPORTS],
|
||||
['/doormile/customers', { ...DEFAULT_CONTEXT, label: 'Customers' }],
|
||||
[
|
||||
'/doormile/customers',
|
||||
{
|
||||
label: 'Customers',
|
||||
suggestions: [
|
||||
{ icon: LuUserPlus, text: 'Create a customer' },
|
||||
{ icon: LuUsers, text: 'How many customers do we have?' },
|
||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||
{ icon: LuPackage, text: 'How many orders today?' }
|
||||
],
|
||||
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
|
||||
}
|
||||
],
|
||||
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
|
||||
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
|
||||
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
|
||||
];
|
||||
|
||||
// ---- every question, from every page ---------------------------------------
|
||||
//
|
||||
// The catalog is the same wherever you are: the assistant can answer about
|
||||
// orders, riders, hubs and the rest regardless of which screen is open, so
|
||||
// hiding a question because you happen to be on Dispatch made it look narrower
|
||||
// than it is. What the page still decides is ORDER — its own questions lead,
|
||||
// then everything else follows, deduplicated by text.
|
||||
//
|
||||
// `more` is retired: AIWelcome renders one flat list now, so a second array
|
||||
// would only be a second place for a question to hide.
|
||||
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
||||
|
||||
// Every question declared anywhere. Two jobs: it is the icon index behind
|
||||
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
|
||||
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
|
||||
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
|
||||
const EVERY_QUESTION = (() => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
ALL_CONTEXTS.forEach((c) => {
|
||||
[...(c.suggestions || []), ...(c.more || [])].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
out.push(q);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The questions that ride along on EVERY page.
|
||||
//
|
||||
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
|
||||
// Orders page offered "How many tenants do we have?" and "Current hub status"
|
||||
// above the composer, questions an operator working orders is not asking. The
|
||||
// strip is a shortcut, and a shortcut that lists everything is a menu.
|
||||
//
|
||||
// What is left is the daily operating picture: what came in, what is stuck,
|
||||
// what got out, what it earned, and the three ways to put work into the system.
|
||||
//
|
||||
// Dropping a question from THIS list does not remove it from the assistant —
|
||||
// it stays typeable, it stays a follow-up, and it still leads on its own page,
|
||||
// because withGlobalQuestions puts each page's own suggestions first. Hubs
|
||||
// still opens on hub questions; they just no longer follow you to Orders.
|
||||
// ---------------------------------------------------------------------------
|
||||
const GLOBAL_TEXTS = new Set([
|
||||
"Give me today's operations summary",
|
||||
'How many orders today?',
|
||||
'Which orders are delayed?',
|
||||
'How many pending orders today?',
|
||||
'How many delivered orders today?',
|
||||
'How many cancelled orders today?',
|
||||
'Morning Batch orders today',
|
||||
'Orders today vs yesterday',
|
||||
'Total revenue today',
|
||||
'How many riders are active?',
|
||||
'Create an order',
|
||||
REPEAT_TEXT,
|
||||
'Create multiple orders'
|
||||
]);
|
||||
|
||||
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
|
||||
|
||||
// The page's own suggestions lead; the global set follows, deduplicated.
|
||||
const withGlobalQuestions = (context) => {
|
||||
const seen = new Set();
|
||||
const suggestions = [];
|
||||
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
|
||||
if (seen.has(q.text)) return;
|
||||
seen.add(q.text);
|
||||
suggestions.push(q);
|
||||
});
|
||||
return { ...context, suggestions, more: [] };
|
||||
};
|
||||
|
||||
export const getPageContext = (pathname = '') => {
|
||||
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
||||
return match ? match[1] : DEFAULT_CONTEXT;
|
||||
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Follow-ups are stored as bare strings (they have to be — they are the exact
|
||||
// text that gets asked), so this turns one back into a renderable chip by
|
||||
// looking its icon up among the questions already declared above. Built from
|
||||
// EVERY_QUESTION rather than a second hand-written list, so a suggestion can
|
||||
// never end up with an icon here that disagrees with its icon on a page.
|
||||
// ---------------------------------------------------------------------------
|
||||
const QUESTION_BY_TEXT = new Map(EVERY_QUESTION.map((q) => [q.text, q]));
|
||||
QUESTION_BY_TEXT.set(ASSIGN_TEXT, { icon: LuBike, text: ASSIGN_TEXT });
|
||||
|
||||
// Returns [] for anything unrecognised — a typed question, or one with no
|
||||
// natural next step. The caller falls back to the page's own suggestions, so
|
||||
// the strip is never empty.
|
||||
export const getFollowUps = (askedText) => {
|
||||
const next = FOLLOW_UPS[askedText];
|
||||
if (!next) return [];
|
||||
return next.map((text) => QUESTION_BY_TEXT.get(text)).filter(Boolean);
|
||||
};
|
||||
|
||||
// Turns arbitrary follow-up strings into chips. Used for the intent-keyed
|
||||
// FOLLOW_UP_SUGGESTIONS in intents.js, whose phrasings ("What about
|
||||
// yesterday?", "This week") are conversational rather than one of the declared
|
||||
// questions, so they have no icon of their own.
|
||||
export const toChips = (texts = []) => texts.map((text) => QUESTION_BY_TEXT.get(text) || { icon: LuCircleDot, text });
|
||||
|
||||
306
src/pages/nearle/assistant/RAG_PLAN.md
Normal file
306
src/pages/nearle/assistant/RAG_PLAN.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Doormile AI — RAG implementation plan
|
||||
|
||||
Retrieval-augmented routing and document Q&A, backed by a local ChromaDB.
|
||||
|
||||
**Status:** plan only. Nothing here is built.
|
||||
**Prerequisite decision:** where the sidecar runs (see §11).
|
||||
|
||||
---
|
||||
|
||||
## 1. What this changes, and what it deliberately does not
|
||||
|
||||
### The problem being solved
|
||||
|
||||
The assistant routes questions with **34 hand-written regex matchers**. That has a hard vocabulary ceiling, and we hit it repeatedly:
|
||||
|
||||
| Phrase | What went wrong |
|
||||
|---|---|
|
||||
| "cancellation rate" | `\bcancel(led)?\b` doesn't match "cancellation" — the `\b` fails on the following `l` |
|
||||
| "8494948494" (a bare reply) | matched no intent at all; the reply was dropped |
|
||||
| "orders per day this week" | `weekOrders` answered with one number and silently dropped "per day" |
|
||||
| "delivered orders for Acme" | tenant discarded until `orderQuery` was built |
|
||||
|
||||
Every one of those was a regex fix. The next ten will be too. **RAG removes the ceiling** — a new phrasing works because it's *semantically near* an example, not because someone wrote a pattern for it.
|
||||
|
||||
### What RAG must NOT do here
|
||||
|
||||
`assistant/CLAUDE.md` §2 records RAG being considered and rejected, and that reasoning stands **for the data**:
|
||||
|
||||
> "this bot's data isn't unstructured documents, it's structured operational data already reachable through typed API functions."
|
||||
|
||||
A vector store is a snapshot. "How many orders today" changes every minute. Answering it from embeddings means answering from whenever we last indexed.
|
||||
|
||||
**So: RAG selects the question. The existing API layer still produces every number.**
|
||||
|
||||
```
|
||||
question ──► embed ──► Chroma ──► top-k intent examples ──► intentId + slots + confidence
|
||||
│
|
||||
▼
|
||||
the SAME deterministic run() executes
|
||||
│
|
||||
▼
|
||||
real API call ──► real number
|
||||
```
|
||||
|
||||
The "never fabricate a number" guarantee survives untouched. Nothing in the vector store ever becomes a figure the operator reads.
|
||||
|
||||
### Why this is not blocked by the §2 decision
|
||||
|
||||
§2's blocker was: *an LLM call needs an API key, and a static CRA build has nowhere to put one.*
|
||||
|
||||
This plan uses a **local embedding model running in Node** (`@xenova/transformers`). No key, no network, no per-call cost. The blocker doesn't apply. If we later want a hosted embedding model or a generation step, §2 applies again and needs its own decision.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
docker-compose.yml
|
||||
├── chroma chromadb/chroma:latest :8000 persistent volume ./.chroma
|
||||
└── ai-sidecar node:20 :8787 services/ai
|
||||
|
||||
services/ai/
|
||||
├── package.json own deps — NOT added to the CRA package.json
|
||||
├── index.js Express app: /health, /route, /ask, /reindex
|
||||
├── embed.js MiniLM via @xenova/transformers, cached in-process
|
||||
├── collections.js Chroma client, collection get-or-create
|
||||
├── seed/
|
||||
│ ├── intents.js builds intent_examples from the phrasing catalog
|
||||
│ ├── docs.js chunks the markdown docs
|
||||
│ └── phrasings.json ~20 example phrasings per intent (hand-written)
|
||||
├── eval.js routing accuracy vs the regex baseline
|
||||
└── README.md how to run it locally
|
||||
```
|
||||
|
||||
**The CRA app gains no new dependencies.** The sidecar is a separate package with its own `package.json`, so `react-scripts`, the webpack config and the `resolutions` block in the root `package.json` are untouched (root `CLAUDE.md` §4.3).
|
||||
|
||||
### Ports and env
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Chroma | `http://localhost:8000` |
|
||||
| Sidecar | `http://localhost:8787` |
|
||||
| CRA reads | `REACT_APP_AI_URL` (absent → RAG disabled, regex only) |
|
||||
|
||||
`REACT_APP_AI_URL` being unset must be a supported state, not a broken one — that is what keeps the app deployable exactly as it is today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### Collection `intent_examples`
|
||||
|
||||
One vector per example phrasing. ~34 intents × ~20 phrasings ≈ **700 vectors**. Trivially small; Chroma handles it in memory.
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'statusBreakdown::07',
|
||||
document: 'how many orders got cancelled this week',
|
||||
metadata: {
|
||||
intentId: 'statusBreakdown',
|
||||
domain: 'orders', // orders | riders | tenants | hubs | vehicles | ops | write
|
||||
isWrite: false, // write intents need a higher bar — see §6
|
||||
slotsHint: 'status,range' // documentation only; slots still parsed deterministically
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Collection `console_docs`
|
||||
|
||||
Chunked markdown for genuine document Q&A — "what is CityGate", "why does dispatch reconcile before commit".
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'express-console-api.md::conventions::2',
|
||||
document: '<chunk text>',
|
||||
metadata: {
|
||||
source: 'express-console-api.md',
|
||||
heading: 'Conventions across every endpoint',
|
||||
updatedAt: '2026-08-19'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Chunking:** split on markdown headings, then hard-wrap at ~800 characters with ~100 characters of overlap. Heading path is prepended to each chunk so a chunk carries its own context.
|
||||
|
||||
**Sources to index:** `express-console-api.md`, root `CLAUDE.md`, `src/pages/api/CLAUDE.md`, `src/pages/nearle/assistant/CLAUDE.md`, `src/pages/nearle/dispatch/CLAUDE.md`, `src/pages/nearle/orders/CLAUDE.md`, `ROADMAP.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. The routing contract
|
||||
|
||||
### `POST /route`
|
||||
|
||||
```jsonc
|
||||
// request
|
||||
{ "text": "how many orders got cancelled this week" }
|
||||
|
||||
// response
|
||||
{
|
||||
"intentId": "statusBreakdown",
|
||||
"confidence": "high", // high | medium | low
|
||||
"score": 0.91, // cosine similarity of top-1
|
||||
"margin": 0.19, // top-1 minus top-2 — the honest signal
|
||||
"alternatives": [
|
||||
{ "intentId": "orderRate", "score": 0.72 }
|
||||
],
|
||||
"matchedExample": "how many orders got cancelled this week"
|
||||
}
|
||||
```
|
||||
|
||||
### How confidence is derived — and why margin, not score
|
||||
|
||||
Cosine similarity is **not** a probability of correctness. 0.87 does not mean "87% likely right". What actually carries information is the **margin** between the best and second-best match:
|
||||
|
||||
| Condition | Confidence | Bot behaviour |
|
||||
|---|---|---|
|
||||
| `score ≥ 0.75` and `margin ≥ 0.10` | **high** | route and answer |
|
||||
| `score ≥ 0.60` and `margin ≥ 0.05` | **medium** | route, and name the interpretation in the answer |
|
||||
| otherwise | **low** | **don't guess** — offer the top 2–3 as buttons |
|
||||
|
||||
Thresholds are starting values, tuned in phase 7 against the eval corpus.
|
||||
|
||||
**What the operator sees:** *"Matched: orders by status · high confidence"* — never *"87% sure the answer is 42."* The answer's correctness comes from deterministic execution; the score only describes how sure we are which question was asked. Conflating the two would be exactly the kind of false precision this bot has avoided all along.
|
||||
|
||||
### `POST /ask` (docs)
|
||||
|
||||
```jsonc
|
||||
{ "text": "what is CityGate" }
|
||||
→ { "chunks": [ { "text": "...", "source": "express-console-api.md",
|
||||
"heading": "Conventions", "score": 0.88 } ] }
|
||||
```
|
||||
|
||||
**No generation step.** It returns the source passages with attribution and the panel renders them. Summarising them into new prose would require an LLM — which is §2's blocked decision — and would also let a paraphrase drift from what the doc says.
|
||||
|
||||
---
|
||||
|
||||
## 5. Embedding model
|
||||
|
||||
**`Xenova/all-MiniLM-L6-v2`** via `@xenova/transformers`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Size | ~25 MB, downloaded once, cached on disk |
|
||||
| Dimensions | 384 |
|
||||
| Runs | in-process in Node — no key, no network, no per-call cost |
|
||||
| Speed | ~5 ms per short query on CPU |
|
||||
|
||||
Good enough for short operator phrases, which is the whole workload. Upgrade path if the eval shows it's not: `text-embedding-3-small`. That needs a key, which re-opens §2 — do not do it silently.
|
||||
|
||||
**Important:** queries and documents must be embedded with the *same* model and the same normalisation. A model change means a full re-seed; `seed.js` writes the model name into collection metadata so a mismatch is detectable rather than silently wrong.
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration with the bot
|
||||
|
||||
`answerQuestion(text, context)` gains a routing step **in front of** the existing matcher:
|
||||
|
||||
```js
|
||||
// 1. try semantic routing, but never let it break the bot
|
||||
const routed = await routeViaRag(text).catch(() => null);
|
||||
|
||||
// 2. high/medium confidence → run that intent's own run()
|
||||
if (routed && routed.confidence !== 'low') {
|
||||
const intent = INTENTS_BY_ID[routed.intentId];
|
||||
const params = intent?.match(text) ?? deriveSlots(text, routed);
|
||||
const result = params && (await intent.run(params));
|
||||
if (result) return { ...result, routing: routed };
|
||||
}
|
||||
|
||||
// 3. fall back to the 34 regex intents, exactly as today
|
||||
return matchAndRun(text);
|
||||
```
|
||||
|
||||
### Non-negotiables
|
||||
|
||||
**The regex matcher stays.** It is the fallback when the sidecar is down, `REACT_APP_AI_URL` is unset, or confidence is low. Today's behaviour is the floor — RAG can only improve it, never remove it.
|
||||
|
||||
**Slots stay deterministic.** `rangeFromWords`, `statusFromWords`, `orderStatusGroups`, entity resolution all keep their jobs. Embeddings are good at *"what kind of question is this"* and bad at *"the 14th"*. RAG picks the intent; parsing extracts the values.
|
||||
|
||||
**Write intents need a higher bar.** `createCustomer` and `createOrder` must require **high** confidence AND an explicit verb match. A semantic near-miss must never open a write form. The `isWrite` flag in metadata exists for exactly this check.
|
||||
|
||||
**Timeout.** 400 ms budget on `/route`; past that, fall through to regex. The bot must never feel slower because a container is cold.
|
||||
|
||||
---
|
||||
|
||||
## 7. Evaluation — how we prove it is better
|
||||
|
||||
The existing **40-phrase routing corpus** (`scratchpad/route_probe.mjs`) becomes the regression baseline. But it is not a fair test: those phrases were written *for* the regex matcher and it scores 40/40 on them.
|
||||
|
||||
The real test is a **held-out set of ~60 phrasings neither implementation was tuned against**, written by someone who hasn't read the matchers. Operator language, not developer language: *"anything stuck?"*, *"what's late"*, *"how'd Kumar do"*.
|
||||
|
||||
`eval.js` reports:
|
||||
|
||||
| Metric | Meaning |
|
||||
|---|---|
|
||||
| Routing accuracy | correct `intentId` — the headline number |
|
||||
| Coverage | % answered at all (regex's weakness: silent no-match) |
|
||||
| False routes | wrong intent answered confidently — **the number that matters most** |
|
||||
| Write safety | zero write intents triggered by non-write phrasings |
|
||||
| p50 / p95 latency | must stay under the 400 ms budget |
|
||||
|
||||
**Ship criterion:** RAG beats regex on accuracy *and* coverage on the held-out set, with **zero** false write routes. A false route is worse than a no-match — it's the same class of failure as the "19 assigned vs 0" bug.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phases
|
||||
|
||||
| # | Deliverable | Acceptance | Effort |
|
||||
|---|---|---|---|
|
||||
| 0 | **Sidecar hosting decision** (§11) | answered | — |
|
||||
| 1 | `docker-compose.yml`, Chroma up, Express `/health` | `curl :8787/health` → ok, Chroma reachable | 0.5 d |
|
||||
| 2 | `embed.js`, model cached, round-trip verified | same text → identical vector twice | 0.5 d |
|
||||
| 3 | `phrasings.json` — 20 per intent × 34 | seeded, count verified | 1.5 d |
|
||||
| 4 | `POST /route` with confidence + margin | returns correct intent for 20 hand checks | 0.5 d |
|
||||
| 5 | Bot integration + fallback + 400 ms timeout | **kill the sidecar mid-session → bot still answers** | 0.5 d |
|
||||
| 6 | `console_docs` + `POST /ask` + panel rendering | "what is CityGate" returns the right passage | 1 d |
|
||||
| 7 | `eval.js` + held-out set + threshold tuning | report produced, thresholds fixed from data | 1 d |
|
||||
|
||||
**Total ≈ 5.5 days.** Phases 1–5 (≈3.5 d) deliver the whole routing win; 6–7 add docs and proof.
|
||||
|
||||
---
|
||||
|
||||
## 9. Operations
|
||||
|
||||
- **`npm run seed:ai`** rebuilds both collections from scratch. Idempotent.
|
||||
- **Docs drift silently.** Re-seed on any change to an indexed markdown file — a CI step, or a pre-commit hook. A stale doc answer is worse than none, because it looks authoritative.
|
||||
- **Chroma persistence** is a bind-mounted `./.chroma` volume. Add to `.gitignore`.
|
||||
- **Model cache** likewise (`.cache/transformers`).
|
||||
- **Nothing in the sidecar touches the Doormile API.** It only routes text. All data access stays in the browser through the existing typed functions, which keeps the tenant scoping and the bearer token exactly where they are today.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **First backend in the project** | Dev-only to start. Deploying it is a separate decision with real ops cost. |
|
||||
| Sidecar down → bot dead | Regex fallback + timeout. Tested explicitly in phase 5. |
|
||||
| Semantic near-miss opens a write form | `isWrite` requires high confidence **and** verb match. Zero-tolerance metric in eval. |
|
||||
| Local model too weak on short phrases | Measured in phase 7. Upgrade path exists but re-opens §2. |
|
||||
| Docs go stale | Re-seed in CI. |
|
||||
| Phrasing catalog becomes a second matcher to maintain | It is data, not code — and unlike regexes, near-misses still work. |
|
||||
| Scope creep into generation | Explicitly out (§12). |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions — needed before phase 1
|
||||
|
||||
1. **Where does the sidecar run?**
|
||||
- **(a) Dev-only** — runs on operator machines / a dev box. Zero ops. RAG is an enhancement that's simply absent in production.
|
||||
- **(b) Deployed alongside nginx** — the project gains a backend: hosting, monitoring, a deploy pipeline, an internal network hop. Bigger commitment than the vector DB itself.
|
||||
|
||||
*Recommendation: (a) first.* Prove routing accuracy with real operator language before taking on ops.
|
||||
|
||||
2. **Does `/ask` (docs) ship to operators, or is it internal?** The indexed docs contain engineering notes, including known backend bugs.
|
||||
|
||||
3. **Who writes the held-out eval set?** It has to be someone who hasn't read the matchers, or the test is worthless.
|
||||
|
||||
---
|
||||
|
||||
## 12. Explicitly out of scope
|
||||
|
||||
- **Any generation step.** `/ask` returns source passages with attribution, never paraphrase. Generation needs a hosted model and a key — §2's blocked decision.
|
||||
- **Embedding operational data.** No bookings, riders or customers in the vector store. Numbers come from the API, always.
|
||||
- **Replacing the regex matcher.** It becomes the fallback, permanently.
|
||||
- **Semantic slot extraction.** Dates, statuses and entities stay deterministic.
|
||||
203
src/pages/nearle/assistant/actions.js
Normal file
203
src/pages/nearle/assistant/actions.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createTenantCustomer } from 'pages/api/doormileApi';
|
||||
|
||||
// ==============================|| Doormile AI — write actions ||============================== //
|
||||
//
|
||||
// The FIRST write capability in the assistant. Read the rules before adding
|
||||
// another one.
|
||||
//
|
||||
// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
|
||||
// PROPOSAL — the exact payload it would submit — and nothing reaches the API
|
||||
// until the operator presses Create in the panel. Parsing, validation and
|
||||
// execution are separated here so the proposal can be built, shown and
|
||||
// discarded without any possibility of a request going out.
|
||||
//
|
||||
// Customer creation was chosen as the first write deliberately: it needs two
|
||||
// required fields where an order needs fourteen, it has no CityGate pincode
|
||||
// gate, no geocoding and no delivery slot, and a wrong record is an edit
|
||||
// rather than a rider dispatched to the wrong address.
|
||||
|
||||
// "create a customer", "add new client" — an explicit verb + noun. Deliberately
|
||||
// narrow: nothing here should fire on a question that merely mentions customers.
|
||||
export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
|
||||
|
||||
// Words that are part of the instruction rather than the person's name.
|
||||
const FILLER =
|
||||
/\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
|
||||
|
||||
// Pulls what it can out of free text. Anything it can't find stays undefined
|
||||
// and is asked for — never guessed.
|
||||
export const parseCustomerDraft = (text) => {
|
||||
const raw = String(text || '');
|
||||
|
||||
const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
|
||||
|
||||
// Exactly ten digits, standalone. A longer run is not a phone number and
|
||||
// must not be silently truncated into one.
|
||||
const phone = (raw.match(/(?<!\d)(\d{10})(?!\d)/) || [])[1];
|
||||
|
||||
// Whatever is left after removing the instruction, the phone and the email
|
||||
// is the person's name.
|
||||
let nameArea = raw;
|
||||
if (email) nameArea = nameArea.replace(email, ' ');
|
||||
if (phone) nameArea = nameArea.replace(phone, ' ');
|
||||
const nameWords = nameArea
|
||||
.replace(FILLER, ' ')
|
||||
.replace(/[^A-Za-z .'-]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 1);
|
||||
|
||||
return {
|
||||
firstname: nameWords[0],
|
||||
lastname: nameWords.slice(1).join(' ') || undefined,
|
||||
phone,
|
||||
email
|
||||
};
|
||||
};
|
||||
|
||||
// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
|
||||
// digits. If the form would refuse it, the assistant refuses it too, rather
|
||||
// than letting the server decide.
|
||||
//
|
||||
// No tenant check: a customer record carries no tenantid, and the documented
|
||||
// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
|
||||
// exists because its dropdown sends a speculative tenantid; the assistant
|
||||
// doesn't send one at all.
|
||||
export const validateCustomerDraft = (draft) => {
|
||||
const missing = [];
|
||||
if (!draft.firstname) missing.push('the customer’s name');
|
||||
if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
|
||||
return { ok: missing.length === 0, missing };
|
||||
};
|
||||
|
||||
// The exact body that will be POSTed.
|
||||
//
|
||||
// Documented body for POST /admin/tenantcustomers is
|
||||
// { firstname, lastname, phone, email } — and a customer record carries NO
|
||||
// tenantid, which is why the tenant field was removed. A real
|
||||
// GET /admin/customers response does carry address, doorno, landmark, suburb,
|
||||
// city, state, postcode, latitude and longitude, so those are sent on a
|
||||
// best-effort basis: empty strings are dropped rather than sent as noise, and
|
||||
// if the server ignores the rest nothing breaks.
|
||||
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||||
|
||||
export const buildCustomerPayload = (draft) =>
|
||||
clean({
|
||||
firstname: draft.firstname,
|
||||
lastname: draft.lastname || '',
|
||||
phone: draft.phone,
|
||||
email: draft.email || '',
|
||||
address: draft.address,
|
||||
doorno: draft.doorno,
|
||||
landmark: draft.landmark,
|
||||
suburb: draft.suburb,
|
||||
city: draft.city,
|
||||
state: draft.state,
|
||||
postcode: draft.postcode,
|
||||
latitude: draft.latitude,
|
||||
longitude: draft.longitude
|
||||
});
|
||||
|
||||
// The ONLY function in the assistant that mutates anything. Called exclusively
|
||||
// from the panel's confirm handler — never from an intent's run().
|
||||
//
|
||||
// ---- Which endpoint, and why -----------------------------------------------
|
||||
//
|
||||
// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
|
||||
// by reading the docs:
|
||||
//
|
||||
// POST /admin/customers → 405 Method Not Allowed (confirmed live)
|
||||
//
|
||||
// 405 is the unambiguous answer: the route exists, and POST is not among its
|
||||
// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
|
||||
// and the server agrees. It was pointed there briefly on explicit instruction;
|
||||
// the live 405 settled it.
|
||||
//
|
||||
// The consequence, which the assistant states in its success message rather
|
||||
// than leaving the operator to discover: a customer created here does NOT
|
||||
// appear on the Customers page, because that page reads GET /admin/customers.
|
||||
// On that resource a customer comes into existence as a side effect of a
|
||||
// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
|
||||
// Guest customer if unknown". A B2C customer is, by design, someone who has
|
||||
// ordered.
|
||||
//
|
||||
// To make created customers visible on that page, one of these has to happen:
|
||||
// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
|
||||
// it changes what that page means, and its edit dialog would then PATCH a
|
||||
// different store by id), or
|
||||
// • the backend adds POST /admin/customers.
|
||||
//
|
||||
// The payload keeps the address fields. The documented body is
|
||||
// { firstname, lastname, phone, email }; the rest are sent best-effort and
|
||||
// ignored if unsupported.
|
||||
export const executeCreateCustomer = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createTenantCustomer',
|
||||
target: 'POST /admin/tenantcustomers',
|
||||
stats: Object.keys(payload).join(', ')
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await createTenantCustomer(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
// doormileApi mutations return the full envelope, so a `success: false`
|
||||
// arrives as a resolved promise, not a rejection.
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server rejected the customer.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
|
||||
};
|
||||
}
|
||||
|
||||
const created = res?.data || res;
|
||||
return {
|
||||
ok: true,
|
||||
id: created?.appcustomerid ?? created?.customerid ?? created?.id,
|
||||
created,
|
||||
message: 'Customer created.',
|
||||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
|
||||
};
|
||||
} catch (err) {
|
||||
// An HTTP failure used to throw straight past this function, and the panel
|
||||
// printed a generic "could not be created" with the status thrown away —
|
||||
// which is the one detail needed to tell "the route does not exist" apart
|
||||
// from "the body was wrong". Report the status, the server's own message,
|
||||
// and name the endpoint.
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
// doormileAxios rejects with the response BODY, not the axios error, so
|
||||
// `err.response` is undefined here — the status arrives as `err.httpStatus`.
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
|
||||
let message;
|
||||
if (status === 405) {
|
||||
// 405 is "the route exists but not this method" — a different fact from
|
||||
// 404, and worth stating precisely so nobody re-tries the same call.
|
||||
message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
|
||||
} else if (status === 404) {
|
||||
message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
|
||||
} else if (status === 400 || status === 422) {
|
||||
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else if (status) {
|
||||
message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||||
} else {
|
||||
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
message,
|
||||
sourceCalls: [
|
||||
{
|
||||
...call,
|
||||
duration,
|
||||
status: 'error',
|
||||
errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
252
src/pages/nearle/assistant/assignActions.js
Normal file
252
src/pages/nearle/assistant/assignActions.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import { getMilers, assignMilerToBooking } from 'pages/api/doormileApi';
|
||||
import { buildMilerLookup, notifyRider } from 'pages/api/api';
|
||||
|
||||
// ==============================|| Doormile AI — assigning a rider ||============================== //
|
||||
//
|
||||
// Fourth write capability. Two endpoints, and which one is correct depends
|
||||
// entirely on how many orders are being assigned:
|
||||
//
|
||||
// ONE order → POST /admin/bookings/:id/assign-miler
|
||||
// MANY orders → POST /hub/bookings/batch-assign
|
||||
//
|
||||
// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only
|
||||
// place stops get ordered" — after assigning it sends each affected rider's
|
||||
// whole active set to the route optimiser and writes step, per-leg distance and
|
||||
// ETA back onto `bookingassignments`. Assigning ten orders with ten single
|
||||
// calls leaves every route unsequenced and riders choosing their own order.
|
||||
//
|
||||
// ---- The two rider IDs -----------------------------------------------------
|
||||
//
|
||||
// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify`
|
||||
// keys off a **milerprofileid**. Different identity spaces on adjacent
|
||||
// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails
|
||||
// quietly in both directions: the assign 404s, or the rider is never told.
|
||||
// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already
|
||||
// uses for exactly this translation. Don't grow a second lookup here.
|
||||
//
|
||||
// ---- Assignment is normally automatic --------------------------------------
|
||||
//
|
||||
// Creating a booking publishes `booking.assignment_requested`; a worker finds a
|
||||
// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times,
|
||||
// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a
|
||||
// decision the backend is already making, which is why the flow re-reads the
|
||||
// booking's current assignee before offering to change it.
|
||||
|
||||
export const ASSIGN_TRIGGER =
|
||||
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
|
||||
|
||||
// Riders, plus the id bridge, in one call.
|
||||
export const loadRiders = async () => {
|
||||
const milers = (await getMilers()) || [];
|
||||
return { milers, lookup: buildMilerLookup(milers) };
|
||||
};
|
||||
|
||||
const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`;
|
||||
|
||||
// Available riders first — an operator picking by hand wants the ones who can
|
||||
// actually take it at the top. Beyond that, alphabetical: any other ordering
|
||||
// (nearest, least loaded) would need position data this list doesn't carry, and
|
||||
// a proximity label nobody can stand behind is worse than none.
|
||||
const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || ''));
|
||||
|
||||
export const riderOptions = (milers) =>
|
||||
[...(milers || [])]
|
||||
.sort((a, b) => {
|
||||
const byAvail = Number(isAvailable(b)) - Number(isAvailable(a));
|
||||
return byAvail || riderName(a).localeCompare(riderName(b));
|
||||
})
|
||||
.map((m) => ({
|
||||
value: String(m.userid),
|
||||
label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '),
|
||||
record: m
|
||||
}));
|
||||
|
||||
// Who currently holds this booking, resolved to a person rather than an id.
|
||||
export const currentAssignee = (booking, lookup) =>
|
||||
booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null;
|
||||
|
||||
export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null);
|
||||
|
||||
// ---- one order --------------------------------------------------------------
|
||||
export const executeAssign = async (booking, rider) => {
|
||||
const started = Date.now();
|
||||
const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`;
|
||||
const call = {
|
||||
name: 'assignMilerToBooking',
|
||||
target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`,
|
||||
stats: `${bookingLabel} → ${riderName(rider)}`
|
||||
};
|
||||
|
||||
try {
|
||||
// mileruserid, NOT milerprofileid. See the note at the top of this file.
|
||||
const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) });
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server refused the assignment.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
||||
};
|
||||
}
|
||||
|
||||
const sourceCalls = [{ ...call, duration, status: 'complete' }];
|
||||
|
||||
// CLAUDE.md §9: any mutation that affects a rider is followed by a push.
|
||||
// It is deliberately NOT allowed to fail the assignment — the order IS
|
||||
// assigned at this point, and reporting otherwise would be a lie.
|
||||
// Whether the push ACTUALLY went out, not whether it could have been
|
||||
// attempted. This used to be reported as `Boolean(rider.milerprofileid)`
|
||||
// — i.e. "this rider has an id, so assume they were told" — which is a
|
||||
// different claim entirely. Caught live: notify returned 400 and the
|
||||
// assistant still said "The rider has been notified."
|
||||
//
|
||||
// The assignment itself is unaffected; it had already landed. But a
|
||||
// dispatcher who believes a rider was pinged does not follow up, and this
|
||||
// bot's whole contract is that it never states something it has not
|
||||
// confirmed.
|
||||
let notified = false;
|
||||
|
||||
if (rider.milerprofileid) {
|
||||
try {
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified = true;
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
||||
status: 'complete',
|
||||
stats: 'rider notified'
|
||||
});
|
||||
} catch (err) {
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
|
||||
status: 'error',
|
||||
errorMessage: err.message || 'notification failed'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No profile id means no push is possible — say so rather than letting
|
||||
// the operator assume the rider's phone buzzed.
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: '/admin/milers/:id/notify',
|
||||
status: 'error',
|
||||
errorMessage: 'this rider has no milerprofileid, so no notification could be sent'
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, rider, bookingLabel, notified, sourceCalls };
|
||||
} catch (err) {
|
||||
// doormileAxios rejects with the response BODY; the status rides on
|
||||
// err.httpStatus.
|
||||
const status = err.httpStatus;
|
||||
const message = status
|
||||
? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${
|
||||
err.message ? ` — ${err.message}` : ''
|
||||
}. Nothing changed.`
|
||||
: `${err.message || 'The request failed'} — nothing changed.`;
|
||||
return {
|
||||
ok: false,
|
||||
message,
|
||||
sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ---- repeat a run, keeping each order with the rider who ran it last --------
|
||||
//
|
||||
// A repeated run is the SAME drops to the SAME doors. The rider who did them
|
||||
// yesterday already knows the buzzer, the gate code and which side of the
|
||||
// building to park on, so re-deriving an assignment from scratch throws away
|
||||
// the one piece of routing knowledge the previous day produced.
|
||||
//
|
||||
// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
|
||||
// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
|
||||
// riders, which is the opposite of the intent here, and it is refused to every
|
||||
// non-hub login anyway (403, confirmed live).
|
||||
//
|
||||
// The trade-off this accepts: assigning individually does not sequence a
|
||||
// rider's stops. Yesterday's run was already sequenced for these same drops,
|
||||
// so the ordering is not arbitrary — but it is not recomputed either, and the
|
||||
// caller states that rather than implying a fresh optimisation.
|
||||
export const executeRepeatAssign = async (createdPairs, rows) => {
|
||||
const started = Date.now();
|
||||
|
||||
// Only rows whose source order actually had a rider. A blank one is not a
|
||||
// failure — yesterday's copy was never assigned either.
|
||||
const targets = (createdPairs || [])
|
||||
.map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
|
||||
.filter((t) => t.mileruserid != null);
|
||||
|
||||
if (!targets.length) {
|
||||
return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
|
||||
}
|
||||
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
const assignedRiders = new Set();
|
||||
const failures = [];
|
||||
let assigned = 0;
|
||||
|
||||
// Sequential on purpose. These are writes against real dispatch records, and
|
||||
// firing a burst of them concurrently makes a partial failure much harder to
|
||||
// report accurately — which order did not land, and to whom.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const t of targets) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
|
||||
assigned += 1;
|
||||
assignedRiders.add(String(t.mileruserid));
|
||||
} catch (err) {
|
||||
failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCalls = [
|
||||
{
|
||||
name: 'assignMilerToBooking',
|
||||
target: 'POST /admin/bookings/:id/assign-miler',
|
||||
duration: `${Date.now() - started}ms`,
|
||||
status: failures.length ? 'error' : 'complete',
|
||||
stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
|
||||
errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
|
||||
}
|
||||
];
|
||||
|
||||
// One push per rider, not per order — a rider getting ten of yesterday's
|
||||
// drops back should feel one buzz, not ten.
|
||||
let notified = 0;
|
||||
if (lookup) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const userid of assignedRiders) {
|
||||
const rider = lookup.byUserId.get(userid);
|
||||
if (rider?.milerprofileid) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await notifyRider(rider.milerprofileid);
|
||||
notified += 1;
|
||||
} catch {
|
||||
// Notification failure never fails the assignment — the order IS
|
||||
// assigned by this point. It is reported, not swallowed.
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceCalls.push({
|
||||
name: 'notifyRider',
|
||||
target: 'POST /admin/milers/:id/notify',
|
||||
status: notified === assignedRiders.size ? 'complete' : 'error',
|
||||
stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: assigned > 0,
|
||||
assigned,
|
||||
skipped: (createdPairs || []).length - targets.length,
|
||||
failures,
|
||||
notified,
|
||||
riders: assignedRiders.size,
|
||||
sourceCalls
|
||||
};
|
||||
};
|
||||
200
src/pages/nearle/assistant/assignFlow.js
Normal file
200
src/pages/nearle/assistant/assignFlow.js
Normal file
@@ -0,0 +1,200 @@
|
||||
import { scanBookings } from './intents';
|
||||
import { getStatusMeta } from 'themes/dt/status';
|
||||
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational assign ||============================== //
|
||||
//
|
||||
// Reached two ways, and the difference is only what the draft is seeded with:
|
||||
//
|
||||
// • straight after creating an order — the panel seeds `booking`, so the
|
||||
// first question is already about a known order
|
||||
// • "assign a rider to DM-BK-…" — the operator names it, and the first step
|
||||
// resolves that reference against the real booking list
|
||||
//
|
||||
// ---- Why there is a "keep or replace" step ---------------------------------
|
||||
//
|
||||
// The backend assigns riders BY ITSELF within seconds of creation and keeps
|
||||
// retrying for ten minutes (doormile-flow.md §3). By the time an operator
|
||||
// answers a dropdown, a rider may already hold the order — one the backend
|
||||
// chose on proximity, which is information this list does not have.
|
||||
//
|
||||
// So the flow re-reads the booking's current assignee and, if there is one,
|
||||
// asks before replacing them. Silently overwriting would throw away a better
|
||||
// decision and strand a rider who has already been told the job is theirs.
|
||||
|
||||
export const ASSIGN_STEPS = [
|
||||
{
|
||||
id: 'bookingno',
|
||||
// A SELECT, not a text field.
|
||||
//
|
||||
// This used to ask "Give me its number — for example DM-BK-0D915D43-33705"
|
||||
// and wait for it to be typed. Two problems with that. An operator does not
|
||||
// know the number by heart, so the question sent them to another screen to
|
||||
// go and read one. And it was the fallback reached whenever a create did
|
||||
// not hand back a booking id — so the moment the API response shape was
|
||||
// anything other than expected, "assign the order I just made" turned into
|
||||
// "recite a 20-character reference".
|
||||
//
|
||||
// A list removes the failure mode rather than patching it: unassigned
|
||||
// orders first, because those are the ones anyone is here to assign.
|
||||
//
|
||||
// `resolve` below is kept, so typing a number still works — the engine runs
|
||||
// it for a picked option too, and it already matches on bookingid.
|
||||
type: 'select',
|
||||
ask: 'Which order should I assign?',
|
||||
// The list is built from a live scan, so it can come back empty — an API
|
||||
// failure, or genuinely no bookings. `resolve` below still accepts a typed
|
||||
// number, so say that rather than leaving Cancel as the only way out.
|
||||
emptyHint: 'I couldn’t load the order list. Type the order number instead — for example DM-BK-0D915D43-33705.',
|
||||
// Seeded by the panel when this follows a create, so it is skipped there —
|
||||
// that path goes straight to the rider list.
|
||||
when: (d) => !d.booking,
|
||||
options: async () => {
|
||||
const scan = await scanBookings();
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
|
||||
return [...(scan.rows || [])]
|
||||
.map((b) => ({ b, holder: lookup ? currentAssignee(b, lookup) : null }))
|
||||
// Unassigned first; the API already returns newest-first, and that
|
||||
// order is preserved within each group by a stable sort.
|
||||
.sort((x, y) => Number(Boolean(x.holder)) - Number(Boolean(y.holder)))
|
||||
// A dropdown is for picking, not for browsing. Past this many the
|
||||
// operator is better served by naming the order.
|
||||
.slice(0, 30)
|
||||
.map(({ b, holder }) => ({
|
||||
value: String(b.bookingid),
|
||||
label: [
|
||||
b.bookingno || `#${b.bookingid}`,
|
||||
// A raw booking carries `status`; `orderstatus` is the mapped
|
||||
// field the LIST pages add. Reading the wrong one made every
|
||||
// option in this dropdown say "Unknown" — seen live.
|
||||
getStatusMeta(b.status ?? b.orderstatus).label,
|
||||
holder ? `held by ${describeRider(holder)}` : 'unassigned'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}));
|
||||
},
|
||||
resolve: async (raw) => {
|
||||
const needle = String(raw || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return { error: 'I need an order number.' };
|
||||
|
||||
const scan = await scanBookings();
|
||||
|
||||
// EXACT matches win across the whole list before any fuzzy one is
|
||||
// considered. The old version tested all three conditions per row inside
|
||||
// a single find(), so row ORDER decided the winner: a row whose
|
||||
// bookingno merely CONTAINED the needle could match before the row whose
|
||||
// bookingid actually equalled it.
|
||||
//
|
||||
// That is not theoretical. Picking from the dropdown sends a bare
|
||||
// numeric bookingid as the answer, and every bookingno ends in a digit
|
||||
// run — so "32143" substring-matched DM-BK-81DFAF19-32143 while some
|
||||
// other booking genuinely had id 32143. The assignment then went to a
|
||||
// different order than the one on screen, which reads as "it said it
|
||||
// assigned but nothing updated".
|
||||
const exact = scan.rows.find(
|
||||
(b) => String(b.bookingid) === needle || String(b.bookingno || '').toLowerCase() === needle
|
||||
);
|
||||
|
||||
// Substring is a convenience for someone typing part of a reference, so
|
||||
// it needs enough characters to identify one order. Below this it is
|
||||
// guesswork — "1" would match most of the list.
|
||||
const MIN_FUZZY = 4;
|
||||
const found =
|
||||
exact ||
|
||||
(needle.length >= MIN_FUZZY
|
||||
? scan.rows.find((b) => String(b.bookingno || '').toLowerCase().includes(needle))
|
||||
: null);
|
||||
if (!found) {
|
||||
return {
|
||||
error: scan.truncated
|
||||
? `I couldn't find ${raw} in the most recent ${scan.scanned.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings. It may be further back than I can scan.`
|
||||
: `I couldn't find an order matching ${raw}. Check the number and try again.`
|
||||
};
|
||||
}
|
||||
// Resolve the current holder HERE, not afterwards. advanceFlow evaluates
|
||||
// keepOrReplace's `when` the instant this step is applied — a lookup that
|
||||
// lands even one tick later means the step is skipped and an
|
||||
// already-assigned order is silently reassigned.
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
return { value: { booking: found, currentRider: lookup ? currentAssignee(found, lookup) : null } };
|
||||
},
|
||||
apply: (d, v) => ({ ...d, booking: v.booking, __currentRider: v.currentRider })
|
||||
},
|
||||
{
|
||||
id: 'keepOrReplace',
|
||||
type: 'select',
|
||||
// The question text is rewritten by the panel to name the current holder;
|
||||
// this is the fallback if that lookup came back empty.
|
||||
ask: 'This order already has a rider. Keep them, or assign someone else?',
|
||||
when: (d) => Boolean(d.__currentRider),
|
||||
options: async (d) => [
|
||||
{ value: 'keep', label: `Keep ${describeRider(d.__currentRider) || 'the current rider'}` },
|
||||
{ value: 'replace', label: 'Assign someone else' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, keepOrReplace: v, __keep: v === 'keep' })
|
||||
},
|
||||
{
|
||||
id: 'mileruserid',
|
||||
type: 'select',
|
||||
ask: 'Which rider should take it?',
|
||||
when: (d) => !d.__keep,
|
||||
options: async () => {
|
||||
const { milers } = await loadRiders();
|
||||
return riderOptions(milers);
|
||||
},
|
||||
// Resolved rather than taken straight from the clicked option.
|
||||
//
|
||||
// `apply` used to read `option?.record`, which is only populated when a
|
||||
// button was CLICKED. Answer this step by typing — a rider's name, or an
|
||||
// id — and option is undefined, so __rider was undefined, and executeAssign
|
||||
// then threw on `rider.userid` inside its try/catch. The operator saw a
|
||||
// failed assignment with "Cannot read properties of undefined" instead of
|
||||
// an answer.
|
||||
//
|
||||
// Resolving here means both paths produce a real miler record, and a name
|
||||
// that matches nobody gets a sentence rather than a crash.
|
||||
resolve: async (raw) => {
|
||||
const needle = String(raw || '').trim();
|
||||
if (!needle) return { error: 'I need a rider.' };
|
||||
|
||||
const { milers, lookup } = await loadRiders();
|
||||
const rider =
|
||||
lookup?.byUserId?.get(needle) ||
|
||||
lookup?.byName?.get(needle.toLowerCase()) ||
|
||||
(milers || []).find((m) => String(m.userid) === needle) ||
|
||||
(milers || []).find((m) => (m.displayname || m.authname || '').toLowerCase() === needle.toLowerCase());
|
||||
|
||||
if (!rider) return { error: `I couldn't find a rider matching ${raw}. Pick one from the list.` };
|
||||
// Both ids travel: assign-miler needs `userid`, the push needs
|
||||
// `milerprofileid`, and they are different fields on the same record.
|
||||
return { value: { mileruserid: String(rider.userid), rider } };
|
||||
},
|
||||
apply: (d, v) => ({ ...d, mileruserid: v.mileruserid, __rider: v.rider })
|
||||
}
|
||||
];
|
||||
|
||||
// `booking` is optional — present when this follows a create.
|
||||
export const startAssignFlow = async (booking) => {
|
||||
const draft = booking ? { booking } : {};
|
||||
if (booking) {
|
||||
// Resolve who holds it right now, so the keep/replace step knows whether to
|
||||
// ask at all. A failure here degrades to "nobody assigned yet", which is
|
||||
// the safe direction: the operator is asked to choose rather than being
|
||||
// told something untrue about the current rider.
|
||||
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
|
||||
const held = lookup ? currentAssignee(booking, lookup) : null;
|
||||
if (held) draft.__currentRider = held;
|
||||
}
|
||||
return startFlow(ASSIGN_STEPS, 'assignRider', draft);
|
||||
};
|
||||
|
||||
export const advanceAssign = (flow) => advanceFlow(ASSIGN_STEPS, flow);
|
||||
|
||||
export const answerAssignStep = (flow, raw, option) => answerFlowStep(ASSIGN_STEPS, flow, raw, option);
|
||||
223
src/pages/nearle/assistant/bulkFile.js
Normal file
223
src/pages/nearle/assistant/bulkFile.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from 'utils/bulkOrderColumns';
|
||||
|
||||
// ==============================|| Doormile AI — bulk order file upload ||============================== //
|
||||
//
|
||||
// Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from
|
||||
// a paste, so everything downstream — geocoding, validation, review, the chunked
|
||||
// submit, the per-row outcome report — is untouched by where the rows came from.
|
||||
//
|
||||
// Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the
|
||||
// ones multipleOrders.js uses, as is the column map. A sheet that uploads on
|
||||
// that page uploads here.
|
||||
//
|
||||
// Three reporting rules, all of them about not lying by omission:
|
||||
//
|
||||
// • An unparseable row becomes a REPORTED error with its line number, never a
|
||||
// silently skipped line. A bulk import that quietly drops row 14 is worse
|
||||
// than one that refuses outright.
|
||||
// • Columns that were not recognised are NAMED. An operator whose price
|
||||
// column is titled something unexpected has to be told it was ignored, or
|
||||
// they'll submit 200 orders priced from a column nothing ever read.
|
||||
// • Rows duplicated inside the file are flagged BEFORE submit. The bulk
|
||||
// endpoint has no idempotency key, so a duplicate that gets through is a
|
||||
// second real rider dispatched to the same door.
|
||||
|
||||
const CSV_EXT = /\.csv$/i;
|
||||
const EXCEL_EXT = /\.xlsx?$/i;
|
||||
|
||||
const digits = (v) => String(v ?? '').replace(/\D/g, '');
|
||||
const text = (v) => String(v ?? '').trim();
|
||||
|
||||
// A sheet cell can be a number, a date, or padded text — normalise to the same
|
||||
// shapes parseBulkRows yields so validateBulkRow behaves identically.
|
||||
const shapeRow = (raw, line) => {
|
||||
const { row, ignored } = mapSheetRow(raw);
|
||||
|
||||
const lat = Number(row.deliverylatitude);
|
||||
const lng = Number(row.deliverylongitude);
|
||||
const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0;
|
||||
|
||||
return {
|
||||
ignored,
|
||||
row: {
|
||||
line,
|
||||
customer_name: text(row.customer_name),
|
||||
// A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91
|
||||
// 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits
|
||||
// and drop a leading country/trunk prefix the same way the single-order
|
||||
// flow does.
|
||||
customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''),
|
||||
deliveryaddress: text(row.deliveryaddress),
|
||||
deliverypincode: digits(row.deliverypincode),
|
||||
deliverycity: text(row.deliverycity),
|
||||
// Blank is meaningful: it means "quote this row from the tenant's pricing
|
||||
// row and the routed distance", the same as the single-order flow. It is
|
||||
// NOT zero.
|
||||
finalprice: text(row.finalprice),
|
||||
itemdescription: text(row.itemdescription) || 'Order',
|
||||
itemcategory: text(row.itemcategory) || 'General',
|
||||
quantity: Math.max(1, Number(row.quantity) || 1),
|
||||
weight: text(row.weight),
|
||||
// Coordinates from the sheet let the geocode pass skip this row entirely,
|
||||
// which on a 200-row file is the difference between minutes and seconds.
|
||||
...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {})
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Structural check only — enough to know the row is worth geocoding. The full
|
||||
// gate is validateBulkRow, applied after coordinates exist.
|
||||
const structuralError = (row) => {
|
||||
if (!row.customer_name) return 'No receiver name';
|
||||
if (!row.deliveryaddress) return 'No delivery address';
|
||||
if (!row.customer_phone) return 'No phone number';
|
||||
if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapSheetRecords = (records, headers, sheetName) => {
|
||||
const rows = [];
|
||||
const errors = [];
|
||||
const ignoredColumns = new Set();
|
||||
|
||||
records.forEach((raw, i) => {
|
||||
// +2: the header row is line 1, so the first data row is line 2 — the line
|
||||
// number an operator sees in their own spreadsheet.
|
||||
const line = i + 2;
|
||||
const { row, ignored } = shapeRow(raw, line);
|
||||
ignored.forEach((c) => ignoredColumns.add(c));
|
||||
|
||||
// A trailing blank row is an artefact of the file, not an operator error.
|
||||
if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return;
|
||||
|
||||
const error = structuralError(row);
|
||||
if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error });
|
||||
else rows.push(row);
|
||||
});
|
||||
|
||||
const normalised = headers.map(normalizeHeader);
|
||||
const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c)));
|
||||
|
||||
return {
|
||||
rows,
|
||||
errors,
|
||||
sheetName,
|
||||
ignoredColumns: [...ignoredColumns],
|
||||
// Reported, not enforced: the page only warns about these too, and a
|
||||
// hand-built sheet using plain headers ("name", "phone") legitimately has
|
||||
// none of the tenant's official titles while still being complete.
|
||||
missingRequired,
|
||||
recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()),
|
||||
duplicates: findDuplicateRows(rows)
|
||||
};
|
||||
};
|
||||
|
||||
// Same recipient at the same address twice in one file. Reported, never removed
|
||||
// automatically — two parcels to one door is a legitimate order, and deciding
|
||||
// which is which is the operator's call, not the parser's.
|
||||
export const findDuplicateRows = (rows) => {
|
||||
const seen = new Map();
|
||||
const dupes = [];
|
||||
rows.forEach((r) => {
|
||||
const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`;
|
||||
if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name });
|
||||
else seen.set(key, r.line);
|
||||
});
|
||||
return dupes;
|
||||
};
|
||||
|
||||
export const parseBulkFile = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file selected.'));
|
||||
return;
|
||||
}
|
||||
const isCsv = CSV_EXT.test(file.name);
|
||||
const isExcel = EXCEL_EXT.test(file.name);
|
||||
if (!isCsv && !isExcel) {
|
||||
reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCsv) {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
dynamicTyping: false,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (!results.data?.length) {
|
||||
reject(new Error('That CSV has a header row but no data rows.'));
|
||||
return;
|
||||
}
|
||||
resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name));
|
||||
},
|
||||
error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`))
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Couldn’t read that file.'));
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const workbook = XLSX.read(e.target.result, { type: 'binary' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
// Only the first sheet is read, and the name is reported back so an
|
||||
// operator whose data sits on "Sheet2" can see which one was used.
|
||||
const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false });
|
||||
if (!records?.length) {
|
||||
reject(new Error(`Sheet “${sheetName}” is empty.`));
|
||||
return;
|
||||
}
|
||||
resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`));
|
||||
} catch (err) {
|
||||
reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`));
|
||||
}
|
||||
};
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
|
||||
// ---- downloads --------------------------------------------------------------
|
||||
|
||||
// Hands the operator a file built from data they already supplied — a Blob
|
||||
// assembled in the page, not a fetch and not an upload.
|
||||
export const downloadCsv = (filename, csv) => {
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toCsv = (headers, rows) =>
|
||||
[headers, ...rows]
|
||||
.map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(','))
|
||||
.join('\r\n');
|
||||
|
||||
// A blank sheet with the exact headers this parser reads, so operators stop
|
||||
// guessing at column titles.
|
||||
export const templateCsv = () =>
|
||||
toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]);
|
||||
|
||||
// The rows that did NOT go through, in the same column shape, so they can be
|
||||
// fixed and re-uploaded. This is what makes a partial success recoverable
|
||||
// without re-submitting the rows that already landed.
|
||||
export const failedRowsCsv = (failed) =>
|
||||
toCsv(
|
||||
[...TEMPLATE_HEADERS, 'Reason'],
|
||||
failed.map((r) => [
|
||||
r.customer_name || '',
|
||||
r.customer_phone || '',
|
||||
r.deliveryaddress || '',
|
||||
r.deliverypincode || '',
|
||||
r.deliverycity || '',
|
||||
r.itemdescription || '',
|
||||
r.quantity ?? 1,
|
||||
r.finalprice ?? '',
|
||||
r.error || r.reason || 'Rejected'
|
||||
])
|
||||
);
|
||||
193
src/pages/nearle/assistant/bulkFlow.js
Normal file
193
src/pages/nearle/assistant/bulkFlow.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational bulk create ||============================== //
|
||||
//
|
||||
// Same conversation shape as orderFlow.js — one question per turn, a dropdown
|
||||
// wherever the page uses one — but the rows come from a sheet instead of being
|
||||
// dictated one field at a time. The form version was replaced on explicit
|
||||
// direction: "don't show it as the form way, it should be like chatting".
|
||||
//
|
||||
// The steps deliberately mirror the single-order flow's opening, because they
|
||||
// ARE the same questions: which tenant, which pickup location, which service.
|
||||
// Only the last step differs — a whole file instead of one recipient.
|
||||
//
|
||||
// What is NOT a step: locating and pricing the rows. Those are a long-running
|
||||
// pass over the whole file (~1 lookup/second), so the panel runs them after the
|
||||
// last answer and reports progress into the conversation. Making them a "step"
|
||||
// would mean a question nobody is being asked.
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
export const BULK_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant are these orders for?',
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
// One pickup location for the whole file — the same shape the bulk page
|
||||
// uses, and what makes a single batch dispatchable.
|
||||
ask: 'Which business location are they all picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
// Refused here rather than after submitting: CityGate runs server-side
|
||||
// before the handler, and would reject every row in the file with an
|
||||
// opaque middleware error.
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level for all of them?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'rows',
|
||||
type: 'rows',
|
||||
ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
|
||||
// The whole parse result is stored, not just the rows: the ignored columns
|
||||
// and in-file duplicates have to be reportable, and a count of rows alone
|
||||
// can't say what was quietly not read.
|
||||
validate: (parsed) =>
|
||||
parsed?.rows?.length
|
||||
? null
|
||||
: 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
|
||||
apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
|
||||
}
|
||||
];
|
||||
|
||||
export const startBulkFlow = () => {
|
||||
// Same seeding rule as the single-order flow: a client login skips the tenant
|
||||
// question, so the id has to be in the draft or the payload sends NaN.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
|
||||
export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
|
||||
|
||||
// ---- the long pass: locate, then price --------------------------------------
|
||||
//
|
||||
// Extracted from the old form so the conversation can run it and narrate it.
|
||||
// Two economies keep a large file practical, and both are load-bearing:
|
||||
//
|
||||
// • a sheet carrying latitude/longitude columns skips the lookup entirely
|
||||
// • results are cached by address, so a re-run after fixing a few rows does
|
||||
// not re-look-up the ones that were already fine
|
||||
//
|
||||
// `shouldStop` is read through a function, never a captured boolean — as state
|
||||
// it was evaluated once at call time and Stop did nothing for 200 rows.
|
||||
export const GEOCODE_INTERVAL_MS = 1100;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
|
||||
export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
|
||||
|
||||
export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
|
||||
const located = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = rows[i];
|
||||
|
||||
if (hasCoords(row)) {
|
||||
located.push(row);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
const key = cacheKey(row);
|
||||
if (cache?.has(key)) {
|
||||
located.push({ ...row, ...cache.get(key) });
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
|
||||
const found = {
|
||||
deliverylatitude: place?.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place?.geometry?.location?.lng?.(),
|
||||
resolvedAddress: place?.formatted_address
|
||||
};
|
||||
cache?.set(key, found);
|
||||
located.push({ ...row, ...found });
|
||||
|
||||
// Only wait after a real request. A cache hit or a sheet coordinate costs
|
||||
// nothing, which is what makes a re-run fast.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Rows never reached because Stop was pressed keep no coordinates, so they
|
||||
// report as unsendable instead of vanishing from the count.
|
||||
if (located.length < rows.length) located.push(...rows.slice(located.length));
|
||||
|
||||
// Only a row that is located AND unpriced needs a routing call. Pricing an
|
||||
// unlocatable row spends an OSRM request just to fail, and re-pricing a row
|
||||
// that carried its own price would overwrite the operator's number.
|
||||
const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
|
||||
|
||||
// Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
|
||||
// ADDRESS lookups; pricing is unthrottled and bounded by what was already
|
||||
// located. Gating it here meant a Stop mid-lookup left every located row
|
||||
// unpriced and therefore unsendable — throwing away exactly the work the
|
||||
// operator is told is kept.
|
||||
const priced = needPricing.length
|
||||
? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
|
||||
: [];
|
||||
const pricedByLine = new Map(priced.map((r) => [r.line, r]));
|
||||
|
||||
const checked = located.map((r) => {
|
||||
const merged = pricedByLine.get(r.line) || r;
|
||||
return {
|
||||
...merged,
|
||||
// The pricing reason is more specific than "Price must be a number", so it
|
||||
// wins when both apply.
|
||||
error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows: checked,
|
||||
valid: checked.filter((r) => !r.error),
|
||||
invalid: checked.filter((r) => r.error)
|
||||
};
|
||||
};
|
||||
|
||||
// How many rows still need a network lookup — the only honest basis for an ETA.
|
||||
export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
|
||||
|
||||
export const batchCount = (n) => Math.ceil(n / BULK_MAX);
|
||||
299
src/pages/nearle/assistant/bulkOrderActions.js
Normal file
299
src/pages/nearle/assistant/bulkOrderActions.js
Normal file
@@ -0,0 +1,299 @@
|
||||
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));
|
||||
// A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
|
||||
// repeated day's run can span several tenants and locations — collapsing
|
||||
// those onto one shared pickup would silently re-address half the orders.
|
||||
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
|
||||
const batches = chunk(payloads, BULK_MAX);
|
||||
|
||||
const sourceCalls = [];
|
||||
let created = 0;
|
||||
const failures = [];
|
||||
// The ids of what actually landed, so the run can be handed straight to
|
||||
// batch-assign without a re-scan of /admin/bookings to find them again.
|
||||
const createdIds = [];
|
||||
// {index, bookingid} — the same ids, but each still tied to its source row.
|
||||
const createdPairs = [];
|
||||
|
||||
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);
|
||||
// Three shapes, most-nested first. The live endpoint returns
|
||||
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
|
||||
// — confirmed against api.doormile.com — and only the two flatter shapes
|
||||
// were checked here. So `res.data` was an object rather than an array,
|
||||
// `res.results` was undefined, perRow fell through to null, and the
|
||||
// whole run was treated as all-or-nothing: the count came out right by
|
||||
// accident (created += batch.length) while EVERY booking id was thrown
|
||||
// away. That is why "Assign the 13 you just created" never appeared —
|
||||
// there were no ids to offer.
|
||||
const perRow = Array.isArray(res?.data?.results)
|
||||
? res.data.results
|
||||
: 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) => {
|
||||
const index = b * BULK_MAX + i;
|
||||
if (r?.success === false || r?.error) failures.push({ index, reason: r.message || r.error || 'Rejected' });
|
||||
else {
|
||||
created += 1;
|
||||
// Paired with the row it came from, not just collected. A bare list
|
||||
// of ids cannot say WHICH row produced which booking, and the
|
||||
// repeat flow needs exactly that to hand each new order back to the
|
||||
// rider who ran it last time.
|
||||
if (r?.bookingid) {
|
||||
createdIds.push(r.bookingid);
|
||||
createdPairs.push({ index, bookingid: r.bookingid });
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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,
|
||||
// Empty when the endpoint returned no per-row array — the caller must treat
|
||||
// "no ids" as "cannot offer assignment", not as "nothing was created".
|
||||
createdIds,
|
||||
createdPairs,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
batches: batches.length,
|
||||
sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
|
||||
};
|
||||
};
|
||||
142
src/pages/nearle/assistant/customerFlow.js
Normal file
142
src/pages/nearle/assistant/customerFlow.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-customer ||============================== //
|
||||
//
|
||||
// Asks for one field at a time, then shows what it will send and waits for
|
||||
// Submit.
|
||||
//
|
||||
// ---- Why this lives here and not in the router ------------------------------
|
||||
//
|
||||
// A first attempt at this shipped and broke immediately: the operator typed
|
||||
// "create customer", was asked for a phone number, replied "8494948494", and
|
||||
// got "I can't answer that one yet".
|
||||
//
|
||||
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
|
||||
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
|
||||
// routed to the fallback and discarded. Threading a partial draft through the
|
||||
// router's `context` didn't help, because the router had already failed to
|
||||
// choose an intent before the draft was ever consulted.
|
||||
//
|
||||
// So the conversation is owned by the PANEL, which checks for an active flow
|
||||
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
|
||||
// the only arrangement where "8494948494" can't be misread as a question.
|
||||
//
|
||||
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
|
||||
// name and a 10-digit phone are required, everything else is optional and
|
||||
// skippable.
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
|
||||
|
||||
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
|
||||
// operator has no way past an optional field except inventing a value.
|
||||
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
|
||||
|
||||
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
|
||||
|
||||
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
|
||||
// `validate` returns an error string to re-ask with, or null to accept.
|
||||
export const CUSTOMER_STEPS = [
|
||||
{
|
||||
field: 'name',
|
||||
ask: 'What’s the customer’s name?',
|
||||
required: true,
|
||||
apply: (draft, text) => {
|
||||
const [firstname, ...rest] = String(text).trim().split(/\s+/);
|
||||
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
|
||||
},
|
||||
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
|
||||
},
|
||||
{
|
||||
field: 'phone',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
required: true,
|
||||
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
|
||||
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
|
||||
// are both accepted rather than rejected on formatting.
|
||||
validate: (text) => {
|
||||
const digits = String(text)
|
||||
.replace(/\D/g, '')
|
||||
.replace(/^91(?=\d{10}$)/, '');
|
||||
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
ask: 'Email address? (say “skip” if there isn’t one)',
|
||||
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
|
||||
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
|
||||
},
|
||||
{
|
||||
field: 'address',
|
||||
ask: 'Address? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
ask: 'City? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
|
||||
},
|
||||
{
|
||||
field: 'postcode',
|
||||
ask: 'Postcode? (or “skip”)',
|
||||
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
|
||||
}
|
||||
];
|
||||
|
||||
// Starts the flow, pre-filling anything already said in the opening message —
|
||||
// "create a customer Ramesh 9876543210" should not then ask for the name and
|
||||
// the phone it was just given.
|
||||
export const startCustomerFlow = (text) => {
|
||||
const draft = parseCustomerDraft(text);
|
||||
return advance({ kind: 'createCustomer', step: 0, draft });
|
||||
};
|
||||
|
||||
// Moves to the next step that still needs an answer. Returns either a question
|
||||
// to ask, or the finished proposal.
|
||||
export const advance = (flow) => {
|
||||
let { step } = flow;
|
||||
const { draft } = flow;
|
||||
|
||||
while (step < CUSTOMER_STEPS.length) {
|
||||
const s = CUSTOMER_STEPS[step];
|
||||
const already = s.field === 'name' ? draft.firstname : draft[s.field];
|
||||
if (already) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step }, ask: s.ask, done: false };
|
||||
}
|
||||
|
||||
// Every step visited. The required check is repeated here rather than
|
||||
// trusted from the walk above, so a skipped-but-required field can never
|
||||
// reach a proposal.
|
||||
const { ok, missing } = validateCustomerDraft(draft);
|
||||
if (!ok) {
|
||||
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
|
||||
}
|
||||
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
|
||||
};
|
||||
|
||||
// Applies one answer. Returns the next question, or the completed proposal, or
|
||||
// a re-ask when the answer didn't validate.
|
||||
export const answerStep = (flow, text) => {
|
||||
const s = CUSTOMER_STEPS[flow.step];
|
||||
if (!s) return advance(flow);
|
||||
|
||||
if (isSkip(text)) {
|
||||
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
|
||||
// Skipped optional field: step past it without writing anything, so the
|
||||
// payload builder drops it rather than sending an empty string.
|
||||
return advance({ ...flow, step: flow.step + 1 });
|
||||
}
|
||||
|
||||
const error = s.validate?.(text);
|
||||
if (error) return { flow, ask: error, done: false, retry: true };
|
||||
|
||||
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
|
||||
};
|
||||
|
||||
// Human-readable summary of what will be sent, for the confirm step.
|
||||
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));
|
||||
83
src/pages/nearle/assistant/flowEngine.js
Normal file
83
src/pages/nearle/assistant/flowEngine.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// ==============================|| Doormile AI — conversational flow engine ||============================== //
|
||||
//
|
||||
// One step-walker, shared by every conversational create (orderFlow.js,
|
||||
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
|
||||
// create became a conversation too — a second copy would have been a third
|
||||
// definition of the same branching rules to keep in sync.
|
||||
//
|
||||
// A step is a plain object:
|
||||
//
|
||||
// id required. Also the draft key the answer lands on.
|
||||
// type 'select' → the panel renders a dropdown (AIFlowStep)
|
||||
// 'rows' → the panel renders the file/paste input
|
||||
// 'text' → answered through the composer
|
||||
// ask the question
|
||||
// when (draft) => boolean. Omitted means always asked. THIS is branching.
|
||||
// options async (draft) => [{ value, label, record? }] — for 'select'
|
||||
// validate (raw, option) => error | null. Re-asks; stores nothing.
|
||||
// resolve async (raw) => { value } | { error }. May fail and re-ask —
|
||||
// geocoding. A value the rest of the flow depends on is never
|
||||
// stored half-resolved.
|
||||
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
|
||||
// from real data and is only ASKED when that fails, with the reason.
|
||||
// apply (draft, value, option) => draft
|
||||
//
|
||||
// A step is skipped when `when` is false OR when `draft[step.id]` is already
|
||||
// set — which is what lets a caller seed the draft (a client login's tenant) or
|
||||
// one step fill several fields (picking an existing customer).
|
||||
|
||||
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
|
||||
|
||||
// Finds the next step that applies and hasn't been answered. Async because a
|
||||
// step may answer itself from the network before we know whether to ask it.
|
||||
export const advanceFlow = async (steps, flow) => {
|
||||
let { step, draft } = flow;
|
||||
|
||||
while (step < steps.length) {
|
||||
const s = steps[step];
|
||||
if (!applicable(s, draft) || draft[s.id] !== undefined) {
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.auto) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const auto = await s.auto(draft);
|
||||
if (auto?.patch) draft = { ...draft, ...auto.patch };
|
||||
if (auto?.value !== undefined) {
|
||||
draft = s.apply(draft, auto.value);
|
||||
step += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft }, step: s, done: false };
|
||||
}
|
||||
|
||||
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
|
||||
};
|
||||
|
||||
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
|
||||
|
||||
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
|
||||
export const answerFlowStep = async (steps, flow, raw, option) => {
|
||||
const s = steps[flow.step];
|
||||
if (!s) return advanceFlow(steps, flow);
|
||||
|
||||
if (s.validate) {
|
||||
const error = s.validate(raw, option);
|
||||
if (error) return { flow, step: s, ask: error, done: false, retry: true };
|
||||
}
|
||||
|
||||
let value = raw;
|
||||
if (s.resolve) {
|
||||
const resolved = await s.resolve(raw);
|
||||
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
|
||||
value = resolved.value;
|
||||
}
|
||||
|
||||
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
|
||||
};
|
||||
@@ -15,12 +15,25 @@ import {
|
||||
getCompetitorBranches,
|
||||
getCarrierPricing,
|
||||
getMilerSummary,
|
||||
getBookingTrack
|
||||
getMilerActivity,
|
||||
trackConsignment,
|
||||
getConsignmentLogs,
|
||||
getAdminTenant,
|
||||
getTenantLocations
|
||||
} from 'pages/api/doormileApi';
|
||||
import { getalltenants, getallridersummary } from 'pages/api/api';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
// Only the read-only half of actions.js belongs here. Tenant resolution,
|
||||
// payload building and execution live in the panel's submit handler — an
|
||||
// intent must have no route to a write.
|
||||
import { CREATE_CUSTOMER_TRIGGER, parseCustomerDraft } from './actions';
|
||||
import { CREATE_ORDER_TRIGGER } from './orderActions';
|
||||
import { ASSIGN_TRIGGER } from './assignActions';
|
||||
import { REPEAT_TRIGGER } from './repeatRuns';
|
||||
import { CREATE_BULK_TRIGGER } from './bulkOrderActions';
|
||||
import { routeQuestion, isRouteTrustworthy, askDocs } from './ragRouter';
|
||||
import { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus, isInGroup, statusesInGroup } from 'utils/orderStatusGroups';
|
||||
|
||||
// ==============================|| Doormile Bot — intent catalog ||============================== //
|
||||
@@ -275,7 +288,10 @@ const batchFromWords = (text) => {
|
||||
// pending branch before the assigned branch sees the word "assigned".
|
||||
const statusFromWords = (text) => {
|
||||
if (/\bpending\b|\bnot\s*assigned\b|\bunassigned\b|\bawaiting\b/i.test(text)) return 'pending';
|
||||
if (/\bcancel(led)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled';
|
||||
// `cancel(led)?` did not match \"cancellation\" — the \b after \"cancel\" fails
|
||||
// against the following 'l', so \"cancellation rate\" fell through to a plain
|
||||
// week count.
|
||||
if (/\bcancel(?:s|led|lation|lations)?\b|\bdeclined?\b|\brejected\b/i.test(text)) return 'cancelled';
|
||||
if (/\bassigned\b|\baccept(ed)?\b/i.test(text)) return 'assigned';
|
||||
if (/\bactive\b|\bin[- ]?transit\b|\bin\s*progress\b|\bon\s*the\s*way\b|\bout\s*for\s*delivery\b/i.test(text)) return 'active';
|
||||
if (/\bundeliver/i.test(text)) return null;
|
||||
@@ -398,7 +414,10 @@ const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
|
||||
// everywhere rather than inside a range. The sentinel bounds keep one code
|
||||
// path: they can never trigger the early-stop, so this always drains to the
|
||||
// page budget and reports `truncated` honestly if the id could be further back.
|
||||
const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
|
||||
// Exported for assignFlow.js, which resolves a typed order number against the
|
||||
// real booking list. It reuses this rather than growing a second scanner with
|
||||
// its own idea of pagination and truncation.
|
||||
export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
|
||||
|
||||
// A count built on a truncated scan is a floor, not a total — say "at least".
|
||||
const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`;
|
||||
@@ -613,6 +632,32 @@ const bestNameMatch = (needle, records, nameOf) => {
|
||||
return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0];
|
||||
};
|
||||
|
||||
// ---- L4 analytics helpers --------------------------------------------------
|
||||
|
||||
// Percentage with one decimal, but only when the denominator is big enough to
|
||||
// mean anything. "100% cancelled" off two orders is noise, not a rate.
|
||||
const rateOf = (part, whole) => (whole > 0 ? `${((part / whole) * 100).toFixed(whole < 20 ? 0 : 1)}%` : '—');
|
||||
|
||||
// Buckets rows by day (for a range) or by hour (for a single day), so a trend
|
||||
// is answered from the same scan every other count uses.
|
||||
const trendBuckets = (rows, byHour) => {
|
||||
const counts = new Map();
|
||||
rows.forEach((b) => {
|
||||
const d = parseDoormileTimestamp(b.createdat);
|
||||
if (!d.isValid()) return;
|
||||
const key = byHour ? d.format('HH:00') : d.format('DD MMM');
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
};
|
||||
|
||||
// A text bar keeps a trend readable in a 428px panel without a chart library.
|
||||
const sparkBar = (n, max) => '█'.repeat(Math.max(1, Math.round((n / Math.max(max, 1)) * 12)));
|
||||
|
||||
const TREND_TRIGGER =
|
||||
/\btrend\b|\bper\s+day\b|\bper\s+hour\b|\bby\s+day\b|\bby\s+hour\b|\bdaily\b|\bhourly\b|\bover\s+time\b|\bbreakdown\s+by\s+(?:day|hour)\b/i;
|
||||
const RATE_TRIGGER = /\brate\b|\bpercentage\b|\bpercent\b|\b%\b|\bratio\b/i;
|
||||
|
||||
const mentionsDelay = (text) =>
|
||||
/\bdelay(ed|s|ing)?\b|\blate\b|\boverdue\b|\bbehind\s+schedule\b|\brunning\s+late\b|\bsla\b|\bbreach(ed|es)?\b|\bat\s*risk\b/i.test(text);
|
||||
|
||||
@@ -670,6 +715,14 @@ const nameAfterKeyword = (text, keyword) => {
|
||||
// riderLookup just because it contains the word "rider".
|
||||
const LOOKUP_TRIGGER = /\b(?:find|where\s+is|status\s+of|search(?:\s+for)?|lookup)\b/i;
|
||||
|
||||
// Questions ABOUT Doormile or about the assistant itself, rather than about
|
||||
// the data. Kept tight: "how many doormile orders today" mentions the name but
|
||||
// is an orders question, and must not be caught here.
|
||||
// `['’]s|s` covers "what's", "what’s" and the apostrophe-less "whats" people
|
||||
// actually type.
|
||||
const ABOUT_TRIGGER =
|
||||
/\b(?:what|who)(?:['’]s|s|\s+is|\s+are)\s+doormile\b|\b(?:tell\s+me\s+)?about\s+doormile\b|\bdoormile\s*\.\s*com\b|\bwho\s+are\s+you\b|\bwhat\s+(?:can|do)\s+you\s+(?:do|answer|help)\b|\bwhat\s+are\s+you\b/i;
|
||||
|
||||
// Guards against the class of bug found live: "how many riders are active
|
||||
// today" contains the word "active", which is ALSO a valid order status
|
||||
// (in-transit) — statusBreakdown's match used to fire on that word alone and
|
||||
@@ -692,13 +745,125 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
|
||||
const MULTI_SPLIT = /\band\b|,|\+|&/i;
|
||||
|
||||
const INTENTS = [
|
||||
{
|
||||
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
|
||||
// contains "orders", so createBulkOrders and createOrder would otherwise
|
||||
// claim it and open a blank create instead of recalling the run.
|
||||
id: 'repeatRun',
|
||||
label: 'Repeat a past day’s orders — e.g. "repeat yesterday’s orders"',
|
||||
match: (text) => (REPEAT_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
headline: 'Let’s repeat a previous run.',
|
||||
form: { kind: 'repeatRun', status: 'open' },
|
||||
sourceCalls: []
|
||||
})
|
||||
},
|
||||
{
|
||||
// Ordered FIRST, ahead of createOrder: "create multiple orders" also
|
||||
// matches CREATE_ORDER_TRIGGER ("create ... orders"), so the bulk trigger
|
||||
// has to get first refusal or every bulk request opens the single form.
|
||||
id: 'createBulkOrders',
|
||||
label: 'Create several orders from a sheet or a paste — e.g. "bulk upload orders"',
|
||||
match: (text) => (CREATE_BULK_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
headline: 'Upload a sheet or paste your rows and I’ll create them.',
|
||||
form: { kind: 'createBulkOrders', status: 'open' },
|
||||
sourceCalls: []
|
||||
})
|
||||
},
|
||||
{
|
||||
// Ordered FIRST, above createCustomer: "create an order" contains
|
||||
// "order", which orderLookup / orderQuery / totalOrders all match on.
|
||||
// Like createCustomer this NEVER mutates — the panel intercepts this and
|
||||
// starts a CONVERSATION (orderFlow.js); the mutation fires only when the
|
||||
// operator presses Create on the priced confirmation at the end.
|
||||
id: 'createOrder',
|
||||
label: 'Create an order — e.g. "create an order"',
|
||||
match: (text) => (CREATE_ORDER_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
headline: 'Let’s build the order.',
|
||||
form: { kind: 'createOrder', status: 'open' },
|
||||
sourceCalls: []
|
||||
})
|
||||
},
|
||||
{
|
||||
// ---- The only write-capable intent -------------------------------------
|
||||
//
|
||||
// Ordered FIRST: "create a customer …" contains the word "customer"
|
||||
// (customerCount matches on it) and usually a 10-digit phone number
|
||||
// (which orderIdFromWords would read as a weak order id). Neither may get
|
||||
// the question.
|
||||
//
|
||||
// This intent NEVER mutates. It returns a proposal — the exact payload it
|
||||
// would submit — and the panel does not send anything until the operator
|
||||
// presses Create. See actions.js.
|
||||
id: 'createCustomer',
|
||||
label: 'Create a customer — e.g. "create a customer"',
|
||||
match: (text) => (CREATE_CUSTOMER_TRIGGER.test(text) ? { text } : null),
|
||||
run: async ({ text }) => {
|
||||
// Opens a FORM rather than collecting fields conversationally.
|
||||
//
|
||||
// The conversational version shipped and immediately failed in use: a
|
||||
// reply of just "8494948494" matches no intent, so answerQuestion routed
|
||||
// it to the "I can't answer that yet" fallback and the number was lost.
|
||||
// Threading a partial draft through `context` did not help, because the
|
||||
// router selects an intent by matching TEXT and a bare phone number
|
||||
// matches nothing. Real inputs remove the parsing step altogether.
|
||||
//
|
||||
// Still no mutation here: this returns the form's initial values, and
|
||||
// the panel submits only when the operator presses Create.
|
||||
const parsed = parseCustomerDraft(text);
|
||||
return {
|
||||
headline: 'Fill this in and I’ll create the customer.',
|
||||
form: {
|
||||
kind: 'createCustomer',
|
||||
status: 'open',
|
||||
initial: parsed
|
||||
},
|
||||
sourceCalls: []
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// Ordered ahead of orderLookup: "assign a rider to DM-BK-…" names an order
|
||||
// number, and orderLookup would otherwise claim it and answer with the
|
||||
// order's details instead of assigning anything.
|
||||
id: 'assignRider',
|
||||
label: 'Assign a rider to an order — e.g. "assign a rider to DM-BK-0D915D43-33705"',
|
||||
match: (text) => (ASSIGN_TRIGGER.test(text) ? { ref: orderIdFromWords(text) } : null),
|
||||
run: async ({ ref }) => {
|
||||
// No order named — the flow will ask for one.
|
||||
if (!ref?.strong) {
|
||||
return { headline: 'Which order should I assign?', form: { kind: 'assignRider', status: 'open' }, sourceCalls: [] };
|
||||
}
|
||||
const scan = await scanBookings();
|
||||
const needle = String(ref.id).toLowerCase();
|
||||
const found = scan.rows.find((b) => String(b.bookingno || '').toLowerCase() === needle || String(b.bookingid) === ref.id);
|
||||
if (!found) {
|
||||
return {
|
||||
headline: `I couldn't find order ${ref.id}.`,
|
||||
detail: truncationNote(scan).trim() || 'Check the order number and try again.',
|
||||
sourceCalls: [scanCall(scan, `no match for "${ref.id}"`)]
|
||||
};
|
||||
}
|
||||
return {
|
||||
headline: `Assigning ${bookingLabel(found)}.`,
|
||||
form: { kind: 'assignRider', status: 'open', booking: found },
|
||||
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`)]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'orderLookup',
|
||||
label: 'Status of a specific order — e.g. "status of order #1234"',
|
||||
label: 'Everything about one order — e.g. "DM-BK-0D915D43-33705" or "status of order #1234"',
|
||||
match: (text) => {
|
||||
if (!/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
|
||||
const ref = orderIdFromWords(text);
|
||||
if (!ref) return null;
|
||||
// A STRONG reference (DM-…, #1234) is the whole question — an operator
|
||||
// pasting a booking number shouldn't have to wrap a sentence around it.
|
||||
// A WEAK one (bare digits) still needs an order/booking/status/where
|
||||
// word, or a stray "42" would be read as an order id.
|
||||
if (!ref.strong && !/\border\b|\bbooking\b|\bstatus\b|\bwhere\b/i.test(text)) return null;
|
||||
return { orderId: ref.id, strong: ref.strong };
|
||||
},
|
||||
run: async ({ orderId, strong }) => {
|
||||
@@ -735,18 +900,21 @@ const INTENTS = [
|
||||
// Resolve the rider to a NAME (the booking only carries an id), and pull
|
||||
// the tracking trail. Both are enrichment: either failing degrades that
|
||||
// one line rather than the whole answer.
|
||||
const [milers, track] = await Promise.all([
|
||||
// A booking carries `appcustomerid`, never the recipient's name — so the
|
||||
// customer store is read to turn it into one. All three are enrichment:
|
||||
// any of them failing costs that one line, not the whole answer.
|
||||
// GET /admin/bookings/:id/track is deliberately NOT called. Its response
|
||||
// shape was never confirmed (express-console-api.md lists it as
|
||||
// written-but-unproven), so it contributed a "Tracking" line nobody could
|
||||
// rely on and an audit entry that reported an error on every order that
|
||||
// simply has no trail yet. Removed on explicit direction — don't add it
|
||||
// back without a confirmed response shape.
|
||||
const [milers, customers] = await Promise.all([
|
||||
found.assignedmileruserid ? getMilers().catch(() => null) : Promise.resolve(null),
|
||||
getBookingTrack(found.bookingid).catch(() => null)
|
||||
found.appcustomerid ? getAdminCustomers().catch(() => null) : Promise.resolve(null)
|
||||
]);
|
||||
const rider = milers ? milers.find((m) => m.userid === found.assignedmileruserid) : null;
|
||||
|
||||
// GET /admin/bookings/:id/track has no documented response shape
|
||||
// (express-console-api.md lists it as written-but-unproven), so read it
|
||||
// defensively: report only what is unambiguously there, and stay silent
|
||||
// rather than rendering fields that may not mean what their names imply.
|
||||
const steps = Array.isArray(track) ? track : null;
|
||||
const lastStep = steps && steps.length ? steps[steps.length - 1] : null;
|
||||
const customer = customers ? customers.find((c) => (c.appcustomerid ?? c.id) === found.appcustomerid) : null;
|
||||
|
||||
const extraCalls = [];
|
||||
if (milers) {
|
||||
@@ -757,21 +925,14 @@ const INTENTS = [
|
||||
stats: rider ? `resolved ${rider.displayname || rider.name}` : 'no match for rider id'
|
||||
});
|
||||
}
|
||||
extraCalls.push(
|
||||
steps
|
||||
? {
|
||||
name: 'getBookingTrack',
|
||||
target: `/admin/bookings/${found.bookingid}/track`,
|
||||
status: 'complete',
|
||||
stats: `${steps.length} events`
|
||||
}
|
||||
: {
|
||||
name: 'getBookingTrack',
|
||||
target: `/admin/bookings/${found.bookingid}/track`,
|
||||
status: 'error',
|
||||
errorMessage: 'No tracking data returned'
|
||||
}
|
||||
);
|
||||
if (customers) {
|
||||
extraCalls.push({
|
||||
name: 'getAdminCustomers',
|
||||
target: '/admin/customers',
|
||||
status: 'complete',
|
||||
stats: customer ? `resolved ${customer.name || customer.phone || `#${found.appcustomerid}`}` : 'no match for customer id'
|
||||
});
|
||||
}
|
||||
|
||||
const riderLine = found.assignedmileruserid
|
||||
? `Rider: ${rider ? rider.displayname || rider.name : `#${found.assignedmileruserid} (name unavailable)`}${
|
||||
@@ -779,19 +940,63 @@ const INTENTS = [
|
||||
}`
|
||||
: 'Not yet assigned to a rider.';
|
||||
|
||||
const service = found.serviceoptions?.[0];
|
||||
const parcels = found.parcels || [];
|
||||
const addr = (a, pin, city) => [a, city, pin].filter(Boolean).join(', ');
|
||||
|
||||
// Every field that is actually on the record, and nothing that isn't — a
|
||||
// row is omitted rather than rendered as "—", so a blank never reads as
|
||||
// "we checked and it's empty" when it means "this booking has no such
|
||||
// field at all".
|
||||
const items = [
|
||||
{ label: 'Status', meta: `${status}${found.status && found.status !== status ? ` (${found.status})` : ''}` },
|
||||
{ label: 'Rider', meta: riderLine.replace(/^Rider: /, '') },
|
||||
customer || found.appcustomerid
|
||||
? {
|
||||
label: 'Customer',
|
||||
meta: customer
|
||||
? [customer.name || [customer.firstname, customer.lastname].filter(Boolean).join(' '), customer.phone]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: `#${found.appcustomerid} (name unavailable)`
|
||||
}
|
||||
: null,
|
||||
found.pickupaddress ? { label: 'Pickup', meta: addr(found.pickupaddress, found.pickuppincode) } : null,
|
||||
found.deliveryaddress ? { label: 'Drop', meta: addr(found.deliveryaddress, found.deliverypincode, found.deliverycity) } : null,
|
||||
service
|
||||
? {
|
||||
label: 'Service',
|
||||
meta: [service.servicetype, service.estimatedprice != null ? `₹${Number(service.estimatedprice).toFixed(2)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}
|
||||
: null,
|
||||
parcels.length
|
||||
? {
|
||||
label: `Parcel${parcels.length === 1 ? '' : `s (${parcels.length})`}`,
|
||||
meta: parcels
|
||||
.slice(0, 3)
|
||||
.map((p) => [p.itemcategory, p.itemdescription].filter(Boolean).join(' · '))
|
||||
.join(' | ')
|
||||
}
|
||||
: null,
|
||||
found.createdat ? { label: 'Created', meta: parseDoormileTimestamp(found.createdat).format('DD MMM YYYY, hh:mm A') } : null,
|
||||
found.updatedat ? { label: 'Last updated', meta: parseDoormileTimestamp(found.updatedat).format('DD MMM YYYY, hh:mm A') } : null,
|
||||
etaOf(found) ? { label: 'Promised by', meta: parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A') } : null,
|
||||
service?.sladueat ? { label: 'SLA due', meta: parseDoormileTimestamp(service.sladueat).format('DD MMM, hh:mm A') } : null,
|
||||
found.consignmentid ? { label: 'Consignment', meta: `#${found.consignmentid}` } : null,
|
||||
found.bookingsource ? { label: 'Source', meta: found.bookingsource } : null,
|
||||
found.notes ? { label: 'Notes', meta: found.notes } : null
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
headline: `Order ${bookingLabel(found)} is ${status}.`,
|
||||
detail: [
|
||||
riderLine,
|
||||
found.pickupaddress ? `Pickup: ${found.pickupaddress}` : null,
|
||||
found.deliveryaddress ? `Drop: ${found.deliveryaddress}` : null,
|
||||
etaOf(found) ? `Promised by ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}` : null,
|
||||
steps && steps.length
|
||||
? `${steps.length} tracking event${steps.length === 1 ? '' : 's'}${lastStep?.status ? `, latest: ${lastStep.status}` : ''}`
|
||||
: null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
list: { title: 'The full record', numbered: false, items },
|
||||
// Lateness is the one thing worth saying before the rows are read.
|
||||
detail:
|
||||
delayState(found, dayjs()) === 'late'
|
||||
? `Past its promised time — it was due ${parseDoormileTimestamp(etaOf(found)).format('DD MMM, hh:mm A')}.`
|
||||
: undefined,
|
||||
sourceCalls: [scanCall(scan, `matched ${bookingLabel(found)}`), ...extraCalls]
|
||||
};
|
||||
}
|
||||
@@ -834,6 +1039,241 @@ const INTENTS = [
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// ---- L3: what has this rider actually done ----------------------------
|
||||
//
|
||||
// orderQuery can already count a named rider's ORDERS, but the rider's own
|
||||
// record carries what the bookings feed cannot: accepted vs rejected,
|
||||
// distance covered, and current duty state. GET /admin/milers/summary is
|
||||
// the confirmed-live shape for that (api.js documents the fields).
|
||||
id: 'riderActivity',
|
||||
label: 'A rider’s own numbers — e.g. "how is rider Kumar doing today"',
|
||||
match: (text) => {
|
||||
if (!mentionsRiders(text)) return null;
|
||||
if (
|
||||
!/\bperformance\b|\bactivity\b|\bdoing\b|\bstats\b|\bhow\s+is\b|\bhow\s+has\b|\bcompleted\b|\brejected\b|\baccepted\b|\bkms?\b|\bdistance\b/i.test(
|
||||
text
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const name = riderNameFromWords(text);
|
||||
if (!name) return null;
|
||||
const range = rangeFromWords(text);
|
||||
if (range) return { name, start: range.start, end: range.end, label: range.label };
|
||||
const day = dayFromWords(text);
|
||||
return { name, start: day, end: day, label: describeDay(day) };
|
||||
},
|
||||
run: async ({ name, start, end, label }) => {
|
||||
const summary = (await getMilerSummary(undefined, start, end)) || [];
|
||||
const list = Array.isArray(summary) ? summary : [summary];
|
||||
const rider = bestNameMatch(name, list, (m) => m.displayname || m.name);
|
||||
if (!rider) return null;
|
||||
|
||||
// The activity feed is optional colour — a failure degrades that one
|
||||
// line rather than the whole answer.
|
||||
const activity = rider.milerprofileid ? await getMilerActivity(rider.milerprofileid, start, end).catch(() => null) : null;
|
||||
|
||||
const assigned = Number(rider.assigned) || 0;
|
||||
const stats = [
|
||||
{ label: 'Assigned', value: assigned, color: STATUS.accepted },
|
||||
{ label: 'Completed', value: Number(rider.completed) || Number(rider.delivered) || 0, color: STATUS.delivered },
|
||||
{ label: 'Rejected', value: Number(rider.rejected) || 0, color: STATUS.cancelled },
|
||||
{ label: 'Cancelled', value: Number(rider.cancelled) || 0, color: STATUS.muted }
|
||||
];
|
||||
|
||||
return {
|
||||
headline: `${rider.displayname || rider.name} — ${rider.availabilitystatus || 'status unknown'} ${label}.`,
|
||||
metric: { value: Number(rider.completed) || Number(rider.delivered) || 0, label: `Completed ${label}` },
|
||||
stats,
|
||||
detail: [
|
||||
assigned ? `Acceptance ${rateOf(assigned - (Number(rider.rejected) || 0), assigned)} of ${assigned} assigned.` : null,
|
||||
rider.riderkms ? `${rider.riderkms} km covered.` : null,
|
||||
rider.hubname ? `Hub: ${rider.hubname}` : null,
|
||||
rider.onduty != null ? `On duty: ${rider.onduty ? 'yes' : 'no'}` : null,
|
||||
Array.isArray(activity) && activity.length ? `${activity.length} activity events in range.` : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
sourceCalls: [
|
||||
{ name: 'getMilerSummary', target: '/admin/milers/summary', status: 'complete', stats: `matched ${rider.displayname || name}` },
|
||||
activity
|
||||
? {
|
||||
name: 'getMilerActivity',
|
||||
target: `/admin/milers/${rider.milerprofileid}/activity`,
|
||||
status: 'complete',
|
||||
stats: `${activity.length} events`
|
||||
}
|
||||
: null
|
||||
].filter(Boolean)
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// ---- L3: follow a parcel ----------------------------------------------
|
||||
id: 'parcelTrack',
|
||||
label: 'Track a parcel — e.g. "track consignment DM-CN-123"',
|
||||
match: (text) => {
|
||||
if (!/\btrack\b|\bconsignment\b|\bparcel\b|\bshipment\b/i.test(text)) return null;
|
||||
const code = text.match(/\b([A-Z]{2,}-[A-Za-z0-9-]+)\b/) || text.match(/#\s*([A-Za-z0-9-]{3,})/);
|
||||
if (!code) return null;
|
||||
return { trackingno: code[1] };
|
||||
},
|
||||
run: async ({ trackingno }) => {
|
||||
const found = await trackConsignment(trackingno).catch(() => null);
|
||||
if (!found) {
|
||||
return {
|
||||
headline: `I couldn’t find a consignment for ${trackingno}.`,
|
||||
detail: 'Check the tracking number — it looks like a code but nothing matched.',
|
||||
sourceCalls: [
|
||||
{ name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'error', errorMessage: 'No match' }
|
||||
]
|
||||
};
|
||||
}
|
||||
const id = found.consignmentid ?? found.id;
|
||||
const logs = id ? await getConsignmentLogs(id).catch(() => null) : null;
|
||||
const events = Array.isArray(logs) ? logs : [];
|
||||
return {
|
||||
headline: `${trackingno} — ${found.status || 'status unknown'}.`,
|
||||
detail: [found.origin ? `From: ${found.origin}` : null, found.destination ? `To: ${found.destination}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
list: events.length
|
||||
? {
|
||||
title: 'Scan history',
|
||||
items: events.map((e) => ({
|
||||
label: e.status || e.event || 'event',
|
||||
meta: e.createdat ? parseDoormileTimestamp(e.createdat).format('DD MMM, hh:mm A') : undefined
|
||||
}))
|
||||
}
|
||||
: undefined,
|
||||
sourceCalls: [
|
||||
{ name: 'trackConsignment', target: `/admin/consignments/track/${trackingno}`, status: 'complete' },
|
||||
logs
|
||||
? { name: 'getConsignmentLogs', target: `/admin/consignments/${id}/logs`, status: 'complete', stats: `${events.length} events` }
|
||||
: null
|
||||
].filter(Boolean)
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// ---- L3: one tenant in depth -------------------------------------------
|
||||
id: 'tenantDetail',
|
||||
label: 'A tenant’s detail — e.g. "tell me about Acme Foods"',
|
||||
match: (text) => {
|
||||
if (!/\btenant\b|\babout\b|\bdetails?\s+(?:of|for)\b/i.test(text)) return null;
|
||||
if (/\bhow many\b/i.test(text)) return null; // that's tenantList
|
||||
const name = tenantNameCandidate(text) || riderNameFromWords(text);
|
||||
return name ? { name } : null;
|
||||
},
|
||||
run: async ({ name }) => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
const match = bestNameMatch(name, tenants, (t) => t.tenantname);
|
||||
if (!match) return null;
|
||||
const [detail, locations] = await Promise.all([
|
||||
getAdminTenant(match.tenantid).catch(() => null),
|
||||
getTenantLocations(match.tenantid).catch(() => null)
|
||||
]);
|
||||
const t = detail || match;
|
||||
const sites = Array.isArray(locations) ? locations : [];
|
||||
return {
|
||||
headline: `${t.tenantname}${t.status ? ` — ${t.status}` : ''}.`,
|
||||
metric: { value: sites.length, label: 'Pickup locations' },
|
||||
detail: [t.primaryemail ? `Email: ${t.primaryemail}` : null, t.contactno ? `Phone: ${t.contactno}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
list: sites.length
|
||||
? { title: 'Locations', items: sites.map((l) => ({ label: l.locationname || `Location ${l.locationid}`, meta: l.pincode })) }
|
||||
: undefined,
|
||||
sourceCalls: [
|
||||
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `matched ${t.tenantname}` },
|
||||
{ name: 'getAdminTenant', target: `/admin/tenants/${match.tenantid}`, status: detail ? 'complete' : 'error' },
|
||||
{
|
||||
name: 'getTenantLocations',
|
||||
target: `/admin/tenants/${match.tenantid}/locations`,
|
||||
status: locations ? 'complete' : 'error',
|
||||
stats: `${sites.length} locations`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// ---- L4: trend over time ------------------------------------------------
|
||||
//
|
||||
// Ordered above the plain counting intents: "orders per day this week"
|
||||
// contains "orders" and a range, so weekOrders would otherwise answer it
|
||||
// with a single number and drop the "per day" entirely.
|
||||
id: 'orderTrend',
|
||||
label: 'Order trend — e.g. "orders per day this week"',
|
||||
match: (text) => {
|
||||
if (mentionsRiders(text) || mentionsDelay(text)) return null;
|
||||
if (!TREND_TRIGGER.test(text)) return null;
|
||||
if (!/\border(s)?\b|\bbooking(s)?\b|\bdeliver/i.test(text)) return null;
|
||||
const byHour = /\bper\s+hour\b|\bhourly\b|\bby\s+hour\b/i.test(text);
|
||||
const range = rangeFromWords(text);
|
||||
if (range) return { start: range.start, end: range.end, label: range.label, byHour };
|
||||
const day = dayFromWords(text);
|
||||
return { start: day, end: day, label: describeDay(day), byHour: true };
|
||||
},
|
||||
run: async ({ start, end, label, byHour }) => {
|
||||
const scan = await fetchBookingsInRange(start, end);
|
||||
const buckets = trendBuckets(scan.rows, byHour);
|
||||
if (!buckets.length) {
|
||||
return { headline: `No orders to chart ${label}.`, sourceCalls: [scanCall(scan, '0 rows')] };
|
||||
}
|
||||
const max = Math.max(...buckets.map(([, n]) => n));
|
||||
const peak = buckets.find(([, n]) => n === max);
|
||||
return {
|
||||
headline: `${countPhrase(scan, scan.rows.length)} order${scan.rows.length === 1 ? '' : 's'} ${label}, peaking at ${max} (${
|
||||
peak[0]
|
||||
}).`,
|
||||
metric: { value: max, label: `Peak ${byHour ? 'hour' : 'day'} — ${peak[0]}` },
|
||||
list: {
|
||||
title: byHour ? 'Orders per hour' : 'Orders per day',
|
||||
numbered: false,
|
||||
items: buckets.map(([k, n]) => ({ label: k, meta: `${sparkBar(n, max)} ${n}` }))
|
||||
},
|
||||
detail: truncationNote(scan).trim() || undefined,
|
||||
sourceCalls: [scanCall(scan, `${scan.rows.length} rows → ${buckets.length} buckets`)]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// ---- L4: rates ---------------------------------------------------------
|
||||
id: 'orderRate',
|
||||
label: 'A rate — e.g. "cancellation rate this week"',
|
||||
match: (text) => {
|
||||
if (mentionsRiders(text) || mentionsDelay(text)) return null;
|
||||
if (!RATE_TRIGGER.test(text)) return null;
|
||||
const group = statusFromWords(text);
|
||||
if (!group) return null;
|
||||
const range = rangeFromWords(text);
|
||||
if (range) return { group, start: range.start, end: range.end, label: range.label };
|
||||
if (mentionsAnyDate(text)) {
|
||||
const day = dayFromWords(text);
|
||||
return { group, start: day, end: day, label: describeDay(day) };
|
||||
}
|
||||
return { group, start: null, end: null, label: null };
|
||||
},
|
||||
run: async ({ group, start, end, label }) => {
|
||||
const scan = start ? await fetchBookingsInRange(start, end) : await scanBookings();
|
||||
const rows = scan.rows;
|
||||
const matched = rows.filter((b) => isInGroup(b.status, group));
|
||||
const noun = ORDER_STATUS_LABELS[group].toLowerCase();
|
||||
const scopeText = label ? ` ${label}` : '';
|
||||
return {
|
||||
headline: `${rateOf(matched.length, rows.length)} of orders are ${noun}${scopeText}.`,
|
||||
metric: { value: rateOf(matched.length, rows.length), label: `${ORDER_STATUS_LABELS[group]} rate${scopeText}` },
|
||||
stats: statusStats(rows),
|
||||
detail:
|
||||
`${matched.length} of ${rows.length} orders.` +
|
||||
(rows.length < 20 ? ' Small sample — treat the percentage loosely.' : '') +
|
||||
(label ? '' : '\nCovers every order, not just today.') +
|
||||
truncationNote(scan),
|
||||
sourceCalls: [scanCall(scan, `${matched.length}/${rows.length} ${noun}`)]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// ---- Composable order questions ----------------------------------------
|
||||
//
|
||||
@@ -985,10 +1425,14 @@ const INTENTS = [
|
||||
}${scopeText}.`,
|
||||
metric: { value: rows.length, label: [filterText, label].filter(Boolean).join(' · ') || 'Orders' },
|
||||
stats: statusStats(rows),
|
||||
list: rows.length
|
||||
? {
|
||||
title: 'Matching orders',
|
||||
items: rows.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
|
||||
}
|
||||
: undefined,
|
||||
detail:
|
||||
(rows.length
|
||||
? `Order IDs: ${rows.slice(0, 10).map(bookingLabel).join(', ')}${rows.length > 10 ? ` …and ${rows.length - 10} more` : ''}`
|
||||
: 'Nothing matched every part of that question.') +
|
||||
(rows.length ? '' : 'Nothing matched every part of that question.') +
|
||||
(label ? '' : '\nCovers every order, not just today.') +
|
||||
truncationNote(scan),
|
||||
sourceCalls
|
||||
@@ -1022,13 +1466,15 @@ const INTENTS = [
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return {
|
||||
headline: `${tenants.length} tenant${tenants.length === 1 ? '' : 's'} total.`,
|
||||
detail: tenants.length
|
||||
? `${tenants
|
||||
.slice(0, 10)
|
||||
.map((t) => t.tenantname)
|
||||
.filter(Boolean)
|
||||
.join(', ')}${tenants.length > 10 ? ` …and ${tenants.length - 10} more` : ''}`
|
||||
: undefined,
|
||||
metric: { value: tenants.length, label: 'Tenants' },
|
||||
// Full list, not the first ten. "…and 4 more" left the operator with
|
||||
// no way to see the rest at all.
|
||||
list: {
|
||||
title: 'Tenants',
|
||||
items: tenants
|
||||
.filter((t) => t.tenantname)
|
||||
.map((t) => ({ label: t.tenantname, meta: t.tenantid != null ? `#${t.tenantid}` : undefined }))
|
||||
},
|
||||
sourceCalls: [{ name: 'getalltenants', target: '/admin/tenants', status: 'complete', stats: `${tenants.length} tenants` }]
|
||||
};
|
||||
}
|
||||
@@ -1083,6 +1529,9 @@ const INTENTS = [
|
||||
return {
|
||||
headline: `${countPhrase(scan, late.length)} order${late.length === 1 ? '' : 's'} running late${scopeNote}.`,
|
||||
metric: { value: late.length, label: `Late${label ? `, ${label}` : ' right now'}` },
|
||||
list: late.length
|
||||
? { title: 'Late orders', items: late.map((b) => ({ label: bookingLabel(b), meta: formatLateness(b, now) })) }
|
||||
: undefined,
|
||||
stats: [
|
||||
{ label: 'Late', value: late.length, color: STATUS.cancelled },
|
||||
{ label: 'Due within 1h', value: buckets['at-risk'].length, color: STATUS.pending },
|
||||
@@ -1091,12 +1540,7 @@ const INTENTS = [
|
||||
],
|
||||
detail:
|
||||
[
|
||||
late.length
|
||||
? `Late: ${late
|
||||
.slice(0, 8)
|
||||
.map((b) => `${bookingLabel(b)} (${formatLateness(b, now)})`)
|
||||
.join(', ')}${late.length > 8 ? ` …and ${late.length - 8} more` : ''}`
|
||||
: 'Nothing is past its promised delivery time.',
|
||||
late.length ? null : 'Nothing is past its promised delivery time.',
|
||||
hubs ? `By hub: ${hubs.map(([hub, n]) => `${hub} ${n}`).join(', ')}` : null,
|
||||
buckets['no-eta'].length
|
||||
? `${buckets['no-eta'].length} open order${
|
||||
@@ -1227,12 +1671,8 @@ const INTENTS = [
|
||||
const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length;
|
||||
return {
|
||||
headline: `${hubs.length} hub${hubs.length === 1 ? '' : 's'} total, ${active} active.`,
|
||||
detail: hubs.length
|
||||
? hubs
|
||||
.slice(0, 10)
|
||||
.map((h) => `${h.hubname || `Hub #${h.hubid}`} — ${h.status || 'unknown'}`)
|
||||
.join('\n') + (hubs.length > 10 ? `\n…and ${hubs.length - 10} more` : '')
|
||||
: undefined,
|
||||
metric: { value: active, label: `Active of ${hubs.length} hubs` },
|
||||
list: { title: 'Hubs', items: hubs.map((h) => ({ label: h.hubname || `Hub #${h.hubid}`, meta: h.status || 'unknown' })) },
|
||||
sourceCalls: [{ name: 'getHubs', target: '/admin/hubs', status: 'complete', stats: `${hubs.length} hubs` }]
|
||||
};
|
||||
}
|
||||
@@ -1459,12 +1899,13 @@ const INTENTS = [
|
||||
batch
|
||||
)} ${describeDay(day)}.`,
|
||||
metric: { value: matched.length, label: `${getBatchLabel(batch)} ${describeDay(day)}` },
|
||||
detail:
|
||||
(matched.length
|
||||
? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${
|
||||
matched.length > 10 ? ` …and ${matched.length - 10} more` : ''
|
||||
}`
|
||||
: 'No orders fall in this batch for that day.') + truncationNote(scan),
|
||||
list: matched.length
|
||||
? {
|
||||
title: `${getBatchLabel(batch)} orders`,
|
||||
items: matched.map((b) => ({ label: bookingLabel(b), meta: ORDER_STATUS_LABELS[groupForBookingStatus(b.status)] }))
|
||||
}
|
||||
: undefined,
|
||||
detail: (matched.length ? '' : 'No orders fall in this batch for that day.') + truncationNote(scan),
|
||||
sourceCalls: [scanCall(scan, `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`)]
|
||||
};
|
||||
}
|
||||
@@ -1613,6 +2054,37 @@ const INTENTS = [
|
||||
sourceCalls: [scanCall(scan, `${rows.length} matched`)]
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
// LAST in the catalog on purpose: every operational intent gets first
|
||||
// refusal, so this can only ever claim a question none of them recognised.
|
||||
// Its trigger is deliberately narrow — a question that merely CONTAINS the
|
||||
// word "doormile" ("how many doormile orders today") must still route to
|
||||
// the intent that can actually count it.
|
||||
id: 'aboutDoormile',
|
||||
label: 'What Doormile is, and what I can answer — e.g. "what is doormile"',
|
||||
match: (text) => (ABOUT_TRIGGER.test(text) ? {} : null),
|
||||
run: async () => ({
|
||||
// Only what this console demonstrably does. Nothing here is a claim about
|
||||
// the company, its coverage, its pricing or its history — none of that is
|
||||
// in this app, and inventing it would be exactly the failure mode the
|
||||
// whole catalog is built to avoid. doormile.com is where that lives.
|
||||
headline: 'Doormile is the delivery operation this console runs.',
|
||||
detail:
|
||||
'From here you manage orders and deliveries, dispatch riders in batches, run hubs and vehicles, and handle tenants, pricing and reports. I answer questions about that live data — I don’t hold Doormile’s own company information, so for anything beyond day-to-day operations see doormile.com.',
|
||||
list: {
|
||||
title: 'What I can answer',
|
||||
numbered: false,
|
||||
items: [
|
||||
{ label: 'Orders', meta: 'counts by status, batch, tenant or rider · revenue · delays · one order by its number' },
|
||||
{ label: 'Riders', meta: 'how many are active · what a named rider has done' },
|
||||
{ label: 'Fleet', meta: 'hubs, vehicles, tripsheets and exceptions' },
|
||||
{ label: 'Business', meta: 'tenants, customers, pricing, consignments, partners' },
|
||||
{ label: 'Creating', meta: 'a customer, one order, or many from a spreadsheet' }
|
||||
]
|
||||
},
|
||||
sourceCalls: []
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1752,6 +2224,31 @@ const answerMultiPart = async (text) => {
|
||||
export async function answerQuestion(text, context = {}) {
|
||||
const normalized = correctTypos(text);
|
||||
|
||||
// ---- Semantic routing (optional) ---------------------------------------
|
||||
//
|
||||
// Tried FIRST, because the regex catalog's weakness is vocabulary, not
|
||||
// logic: "cancellation" not matching `cancel(led)?`, a bare reply matching
|
||||
// nothing, "per day" being dropped. Retrieval fixes the matching problem
|
||||
// without touching how an answer is produced — the intent's own run() still
|
||||
// executes and every number still comes from a live API call.
|
||||
//
|
||||
// Returns null whenever the sidecar is absent, slow, or unsure, in which
|
||||
// case the deterministic matcher below runs exactly as it does today. This
|
||||
// path can only add coverage.
|
||||
const routed = await routeQuestion(normalized);
|
||||
if (isRouteTrustworthy(routed)) {
|
||||
const intent = INTENTS_BY_ID[routed.intentId];
|
||||
// The intent's own match() still extracts the slots — dates, statuses,
|
||||
// tenants, riders. Retrieval decides WHICH question; parsing decides WITH
|
||||
// WHAT. Embeddings are good at the former and unreliable at the latter.
|
||||
const params = intent?.match(normalized);
|
||||
if (intent && params) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await intent.run(params);
|
||||
if (result) return { ...result, intentId: intent.id, params, routing: routed };
|
||||
}
|
||||
}
|
||||
|
||||
// Tried BEFORE the single-intent pass: several intents match on a bare
|
||||
// substring ("revenue" anywhere in the text) and their `run` never
|
||||
// returns null, so on a combined question like "orders and revenue
|
||||
@@ -1774,5 +2271,40 @@ export async function answerQuestion(text, context = {}) {
|
||||
if (followUp) return followUp;
|
||||
}
|
||||
|
||||
// ---- Last resort: is this a question about how the console WORKS? -------
|
||||
//
|
||||
// Reached only when no intent produced an answer. "What is CityGate", "why
|
||||
// does dispatch reconcile before commit", "what's the pagination cap" are
|
||||
// real operator questions that no amount of API access can answer — they're
|
||||
// answered by the documentation.
|
||||
//
|
||||
// Passages are returned VERBATIM with their source. There is no generation
|
||||
// step: summarising would need a hosted model (CLAUDE.md §2) and would let a
|
||||
// paraphrase drift from what the doc actually says. The operator reads the
|
||||
// real words and can see which file they came from.
|
||||
const docs = await askDocs(normalized);
|
||||
if (docs?.chunks?.length) {
|
||||
const best = docs.chunks[0];
|
||||
return {
|
||||
headline: best.heading || 'From the documentation',
|
||||
detail: best.text,
|
||||
list:
|
||||
docs.chunks.length > 1
|
||||
? {
|
||||
title: 'Other passages',
|
||||
numbered: false,
|
||||
items: docs.chunks.slice(1).map((c) => ({ label: c.heading || c.source, meta: c.source }))
|
||||
}
|
||||
: undefined,
|
||||
intentId: 'docsAnswer',
|
||||
sourceCalls: docs.chunks.map((c) => ({
|
||||
name: 'console_docs',
|
||||
target: c.source,
|
||||
status: 'complete',
|
||||
stats: `similarity ${c.score}`
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
152
src/pages/nearle/assistant/orderActions.js
Normal file
152
src/pages/nearle/assistant/orderActions.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createExpressBooking, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
|
||||
// ==============================|| Doormile AI — create order ||============================== //
|
||||
//
|
||||
// Second write capability. Same contract as actions.js: nothing here mutates
|
||||
// except executeCreateOrder, which the panel calls only when the operator
|
||||
// presses Create.
|
||||
//
|
||||
// Order creation is materially riskier than customer creation — a wrong record
|
||||
// dispatches a real rider.
|
||||
//
|
||||
// ⚠ `pickuplocationid` DOES NOT WORK. express-console-api.md documents it as
|
||||
// the way to reference a stored pickup site, and this file was originally
|
||||
// built on it, but createorder1.js records (in two places) that the field
|
||||
// live-500s on POST /admin/expressbooking — a reported backend bug, with no
|
||||
// frontend workaround other than not using it. The page therefore always
|
||||
// sends the RAW pickup fields, copying them out of the chosen saved location.
|
||||
//
|
||||
// This does the same. The operator still picks a saved location — that part
|
||||
// is good UX and keeps CityGate satisfied, since a stored site has already
|
||||
// passed it — but what goes on the wire is pickupaddress / pickuppincode /
|
||||
// pickupcity / pickuplatitude / pickuplongitude, exactly as the page sends.
|
||||
|
||||
export const CREATE_ORDER_TRIGGER = /\b(?:create|add|place|book|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:order|booking|delivery)\b/i;
|
||||
|
||||
// Doormile's own service tiers (express-console-api.md).
|
||||
export const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
|
||||
|
||||
// CityGate is enforced server-side, before the handler runs. Checking it here
|
||||
// too means the operator is told which cities are open BEFORE submitting,
|
||||
// instead of getting an opaque middleware rejection back.
|
||||
export const OPEN_CITY_PREFIXES = {
|
||||
641: 'Coimbatore',
|
||||
600: 'Chennai',
|
||||
560: 'Bengaluru',
|
||||
500: 'Hyderabad',
|
||||
629: 'Nagercoil'
|
||||
};
|
||||
|
||||
export const cityGateFor = (pincode) => {
|
||||
const prefix = String(pincode || '').slice(0, 3);
|
||||
return OPEN_CITY_PREFIXES[prefix] || null;
|
||||
};
|
||||
|
||||
export const loadOrderTenants = async () => (await getalltenants()) || [];
|
||||
|
||||
// Saved pickup sites for a tenant. Each carries address / city / pincode /
|
||||
// coordinates — which is what the payload copies onto the booking, since
|
||||
// `pickuplocationid` itself 500s (see the note at the top of this file).
|
||||
export const loadPickupLocations = async (tenantid) => {
|
||||
if (!tenantid) return [];
|
||||
return (await getTenantLocations(tenantid)) || [];
|
||||
};
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
|
||||
// Mirrors createorder1.js's own gate, plus the two rules the endpoint enforces
|
||||
// that the page leaves to the server (non-empty parcels, CityGate).
|
||||
export const validateOrderDraft = (d) => {
|
||||
const errors = {};
|
||||
if (!d.tenantid) errors.tenantid = 'Choose a tenant';
|
||||
// Still required: the operator picks a saved site so its address/pincode/
|
||||
// coordinates can be copied onto the booking. The id itself is never sent.
|
||||
if (!d.pickuplocationid) errors.pickuplocationid = 'Choose a pickup location';
|
||||
if (!d.customer_name?.trim()) errors.customer_name = 'Required';
|
||||
if (!PHONE_RE.test(String(d.customer_phone || '').trim())) errors.customer_phone = 'Enter exactly 10 digits';
|
||||
if (!d.deliveryaddress?.trim()) errors.deliveryaddress = 'Required';
|
||||
if (!String(d.deliverypincode || '').trim()) errors.deliverypincode = 'Required';
|
||||
if (!d.itemdescription?.trim()) errors.itemdescription = 'Describe what is being sent';
|
||||
if (d.finalprice === '' || d.finalprice == null || Number.isNaN(Number(d.finalprice))) errors.finalprice = 'Enter an amount';
|
||||
|
||||
// Delivery coordinates come from the address search. Without them the
|
||||
// optimiser has nothing to route against, so refuse rather than send a
|
||||
// booking that can never be dispatched.
|
||||
if (!Number.isFinite(Number(d.deliverylatitude)) || !Number.isFinite(Number(d.deliverylongitude))) {
|
||||
errors.deliveryaddress = 'Pick the address from the suggestions so coordinates are captured';
|
||||
}
|
||||
|
||||
return { ok: Object.keys(errors).length === 0, errors };
|
||||
};
|
||||
|
||||
// The exact body that will be POSTed. Mirrors createorder1.js's own payload —
|
||||
// raw pickup fields, never `pickuplocationid` (see the note at the top).
|
||||
//
|
||||
// `pickup` is the saved location record the operator chose; its address,
|
||||
// pincode, city and coordinates are copied onto the booking.
|
||||
export const buildOrderPayload = (d, pickup) => ({
|
||||
tenantid: Number(d.tenantid),
|
||||
pickupaddress: pickup?.address || '',
|
||||
pickuppincode: String(pickup?.pincode || ''),
|
||||
pickupcity: pickup?.city || '',
|
||||
pickuplatitude: Number(pickup?.latitude) || 0,
|
||||
pickuplongitude: Number(pickup?.longitude) || 0,
|
||||
customer_name: d.customer_name.trim(),
|
||||
customer_phone: String(d.customer_phone).trim(),
|
||||
deliveryaddress: d.deliveryaddress.trim(),
|
||||
deliverypincode: String(d.deliverypincode).trim(),
|
||||
deliverycity: d.deliverycity || '',
|
||||
deliverylatitude: Number(d.deliverylatitude),
|
||||
deliverylongitude: Number(d.deliverylongitude),
|
||||
service_option: SERVICE_OPTIONS.includes(d.service_option) ? d.service_option : 'Normal',
|
||||
finalprice: Number(d.finalprice),
|
||||
notes: d.notes || '',
|
||||
// A parcel entry has no quantity field — N items means N entries, which is
|
||||
// how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
|
||||
parcels: Array.from({ length: Math.max(1, Number(d.quantity) || 1) }, () => ({
|
||||
itemcategory: d.itemcategory || 'General',
|
||||
itemdescription: d.itemdescription.trim(),
|
||||
declaredvalue: Number(d.declaredvalue) || 0
|
||||
}))
|
||||
});
|
||||
|
||||
// The only order-writing function in the assistant.
|
||||
export const executeCreateOrder = async (payload) => {
|
||||
const started = Date.now();
|
||||
const call = {
|
||||
name: 'createExpressBooking',
|
||||
target: 'POST /admin/expressbooking',
|
||||
stats: `tenant ${payload.tenantid}, ${payload.parcels.length} parcel${payload.parcels.length === 1 ? '' : 's'}`
|
||||
};
|
||||
try {
|
||||
const res = await createExpressBooking(payload);
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
if (res && res.success === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message: res.message || 'The server rejected the order.',
|
||||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
|
||||
};
|
||||
}
|
||||
const created = res?.data || res;
|
||||
return {
|
||||
ok: true,
|
||||
id: created?.bookingid ?? created?.id,
|
||||
bookingno: created?.bookingno,
|
||||
created,
|
||||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created ${created?.bookingno || created?.bookingid || '—'}` }]
|
||||
};
|
||||
} catch (err) {
|
||||
const duration = `${Date.now() - started}ms`;
|
||||
const status = err.httpStatus ?? err.response?.status;
|
||||
const serverMessage = err.message || err.error;
|
||||
const message =
|
||||
status === 404 || status === 405
|
||||
? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
|
||||
: status
|
||||
? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
|
||||
: `${serverMessage || 'The request failed'} — nothing was saved.`;
|
||||
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
|
||||
}
|
||||
};
|
||||
267
src/pages/nearle/assistant/orderFlow.js
Normal file
267
src/pages/nearle/assistant/orderFlow.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import { getAdminPricing, getAdminCustomers, getTenantLocations } from 'pages/api/doormileApi';
|
||||
import { getalltenants } from 'pages/api/api';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from 'utils/distance';
|
||||
import { geocodeAddress } from 'components/nearle_components/AddressAutocomplete';
|
||||
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
|
||||
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — conversational create-order ||============================== //
|
||||
//
|
||||
// One question at a time, mirroring createorder1.js's own field set and using a
|
||||
// DROPDOWN wherever that page uses one — business location, customer, category,
|
||||
// weight, service tier. Free text is only for things that genuinely are free
|
||||
// text (a name, an address, a description).
|
||||
//
|
||||
// Two capabilities the customer flow didn't need:
|
||||
//
|
||||
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
|
||||
// straight to picking from the real customer list, new asks for the fields.
|
||||
// Steps carry a `when` predicate and are skipped when it's false.
|
||||
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
|
||||
// dropdown never shows a stale or invented list.
|
||||
//
|
||||
// The conversation is driven by the PANEL, which intercepts replies before the
|
||||
// router ever sees them — see customerFlow.js for why that matters (a bare
|
||||
// "8494948494" matches no intent and used to be discarded).
|
||||
|
||||
const PHONE_RE = /^\d{10}$/;
|
||||
const digits = (t) => String(t || '').replace(/\D/g, '');
|
||||
|
||||
const isStaffLogin = () => {
|
||||
const t = localStorage.getItem('tenantid');
|
||||
return !t || t === '0';
|
||||
};
|
||||
|
||||
// ---- steps ------------------------------------------------------------------
|
||||
//
|
||||
// type: 'select' → the panel renders a dropdown from `options(draft)`
|
||||
// 'text' → answered through the composer
|
||||
// when: omitted means always asked
|
||||
export const ORDER_STEPS = [
|
||||
{
|
||||
id: 'tenantid',
|
||||
type: 'select',
|
||||
ask: 'Which tenant is this order for?',
|
||||
// A client login already has its tenant; only Doormile staff choose.
|
||||
when: () => isStaffLogin(),
|
||||
options: async () => {
|
||||
const tenants = (await getalltenants()) || [];
|
||||
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
},
|
||||
apply: (d, v) => ({ ...d, tenantid: v })
|
||||
},
|
||||
{
|
||||
id: 'pickuplocationid',
|
||||
type: 'select',
|
||||
ask: 'Which business location is this picked up from?',
|
||||
options: async (d) => {
|
||||
const tid = d.tenantid || localStorage.getItem('tenantid');
|
||||
const locations = (await getTenantLocations(tid)) || [];
|
||||
return locations.map((l) => ({
|
||||
value: String(l.locationid),
|
||||
// The pincode is shown because it decides CityGate — a location outside
|
||||
// the open cities will be refused server-side, and the operator should
|
||||
// see that before choosing rather than after submitting.
|
||||
label: `${l.locationname || l.address || 'Location'}${
|
||||
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
|
||||
}`,
|
||||
record: l
|
||||
}));
|
||||
},
|
||||
validate: (v, option) =>
|
||||
cityGateFor(option?.record?.pincode)
|
||||
? null
|
||||
: `That location’s pincode (${
|
||||
option?.record?.pincode || 'unknown'
|
||||
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
|
||||
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
|
||||
},
|
||||
{
|
||||
id: 'customerMode',
|
||||
type: 'select',
|
||||
ask: 'Is this an existing customer, or a new one?',
|
||||
options: async () => [
|
||||
{ value: 'existing', label: 'Existing customer' },
|
||||
{ value: 'new', label: 'New customer' }
|
||||
],
|
||||
apply: (d, v) => ({ ...d, customerMode: v })
|
||||
},
|
||||
{
|
||||
id: 'existingCustomer',
|
||||
type: 'select',
|
||||
ask: 'Which customer?',
|
||||
when: (d) => d.customerMode === 'existing',
|
||||
options: async () => {
|
||||
const customers = (await getAdminCustomers()) || [];
|
||||
return customers
|
||||
.filter((c) => c.phone)
|
||||
.map((c) => ({
|
||||
value: String(c.appcustomerid ?? c.id),
|
||||
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
|
||||
record: c
|
||||
}));
|
||||
},
|
||||
// Picking an existing customer fills the name and phone, so the two
|
||||
// free-text steps below are skipped by their own `when`.
|
||||
apply: (d, v, option) => ({
|
||||
...d,
|
||||
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
|
||||
customer_phone: digits(option?.record?.phone)
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'customer_name',
|
||||
type: 'text',
|
||||
ask: 'What’s the customer’s name?',
|
||||
when: (d) => d.customerMode === 'new' && !d.customer_name,
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
|
||||
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'customer_phone',
|
||||
type: 'text',
|
||||
ask: 'And their 10-digit mobile number?',
|
||||
when: (d) => !d.customer_phone,
|
||||
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
|
||||
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
|
||||
},
|
||||
{
|
||||
id: 'deliveryaddress',
|
||||
type: 'text',
|
||||
ask: 'Where is it being delivered? Give the full address.',
|
||||
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
|
||||
// an address that can't be located is refused here rather than becoming a
|
||||
// booking nothing can dispatch.
|
||||
resolve: async (t) => {
|
||||
const place = await geocodeAddress(String(t).trim()).catch(() => null);
|
||||
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
|
||||
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
|
||||
(place.address_components || []).forEach((c) => {
|
||||
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
|
||||
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
|
||||
});
|
||||
return {
|
||||
value: {
|
||||
...parts,
|
||||
deliverylatitude: place.geometry?.location?.lat?.(),
|
||||
deliverylongitude: place.geometry?.location?.lng?.()
|
||||
}
|
||||
};
|
||||
},
|
||||
apply: (d, v) => ({ ...d, ...v })
|
||||
},
|
||||
{
|
||||
id: 'deliverypincode',
|
||||
type: 'text',
|
||||
ask: 'What’s the delivery pincode?',
|
||||
when: (d) => !d.deliverypincode,
|
||||
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
|
||||
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
|
||||
},
|
||||
{
|
||||
id: 'service_option',
|
||||
type: 'select',
|
||||
ask: 'Which service level?',
|
||||
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
|
||||
apply: (d, v) => ({ ...d, service_option: v })
|
||||
},
|
||||
{
|
||||
id: 'itemcategory',
|
||||
type: 'select',
|
||||
ask: 'What kind of parcel is it?',
|
||||
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||
apply: (d, v) => ({ ...d, itemcategory: v })
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
type: 'select',
|
||||
ask: 'Roughly how heavy?',
|
||||
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
|
||||
apply: (d, v) => ({ ...d, weight: v })
|
||||
},
|
||||
{
|
||||
id: 'itemdescription',
|
||||
type: 'text',
|
||||
ask: 'Briefly, what’s inside?',
|
||||
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
|
||||
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
|
||||
},
|
||||
{
|
||||
id: 'quantity',
|
||||
type: 'select',
|
||||
ask: 'How many parcels?',
|
||||
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
|
||||
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
|
||||
},
|
||||
{
|
||||
id: 'finalprice',
|
||||
type: 'text',
|
||||
ask: 'What should the price be? Enter the amount in ₹.',
|
||||
// `auto` answers a step from real data and only falls back to asking. The
|
||||
// quote is stashed either way so the confirmation can show the distance it
|
||||
// measured, and say why it couldn’t price when it couldn’t.
|
||||
auto: async (d) => {
|
||||
const quote = await priceOrder(d);
|
||||
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
|
||||
return {
|
||||
patch: { __quote: quote },
|
||||
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
|
||||
};
|
||||
},
|
||||
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
|
||||
apply: (d, t) => ({ ...d, finalprice: Number(t) })
|
||||
}
|
||||
];
|
||||
|
||||
// Mirrors createorder1.js's own lists so the bot offers the same choices.
|
||||
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
|
||||
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
|
||||
|
||||
// ---- pricing ----------------------------------------------------------------
|
||||
//
|
||||
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
|
||||
// this tenant's own pricing row. Quoted, never invented — if no pricing row
|
||||
// matches, the operator is asked for the amount rather than shown a zero.
|
||||
export const priceOrder = async (draft) => {
|
||||
const tid = draft.tenantid || localStorage.getItem('tenantid');
|
||||
const pricing = (await getAdminPricing()) || [];
|
||||
// tenantid is numeric on the pricing row and a string from localStorage — a
|
||||
// strict comparison here silently priced every order at zero once before.
|
||||
const match = pricing.find((p) => String(p.tenantid) === String(tid));
|
||||
|
||||
const pickup = draft.__pickup;
|
||||
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
|
||||
|
||||
const km = await calculateDrivingDistance(
|
||||
{ latitude: pickup.latitude, longitude: pickup.longitude },
|
||||
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
|
||||
).catch(() => null);
|
||||
|
||||
if (km == null) return { error: 'could not measure the distance' };
|
||||
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
|
||||
|
||||
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
|
||||
return {
|
||||
km,
|
||||
durationMin: getLastRouteDurationMin(),
|
||||
basePrice: match.baseprice,
|
||||
total: Number(Number(total).toFixed(2))
|
||||
};
|
||||
};
|
||||
|
||||
// ---- engine -----------------------------------------------------------------
|
||||
//
|
||||
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
|
||||
// These wrappers keep the order-specific names the panel and the tests use.
|
||||
|
||||
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
|
||||
|
||||
export const startOrderFlow = () => {
|
||||
// A client login already belongs to a tenant, so its step is skipped — but
|
||||
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
|
||||
// draft is what makes the skip safe.
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
|
||||
};
|
||||
|
||||
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);
|
||||
66
src/pages/nearle/assistant/ragRouter.js
Normal file
66
src/pages/nearle/assistant/ragRouter.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ==============================|| Doormile AI — semantic routing client ||============================== //
|
||||
//
|
||||
// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
|
||||
// asked. It never returns data — every figure still comes from the intent's own
|
||||
// deterministic run(), through the same typed API functions the pages use.
|
||||
//
|
||||
// The whole module is optional by design:
|
||||
//
|
||||
// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
|
||||
// • sidecar unreachable → disabled for this call, regex matcher
|
||||
// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
|
||||
// • low confidence → not used, regex matcher
|
||||
//
|
||||
// Today's behaviour is the floor. This can raise it, never lower it.
|
||||
|
||||
const BASE = process.env.REACT_APP_AI_URL || '';
|
||||
const ROUTE_TIMEOUT_MS = 400;
|
||||
|
||||
export const isRagEnabled = () => Boolean(BASE);
|
||||
|
||||
// Once the sidecar has failed we stop hammering it on every keystroke-fast
|
||||
// question. Re-armed after a cool-off so a restarted container is picked up
|
||||
// without a page reload.
|
||||
let disabledUntil = 0;
|
||||
const COOL_OFF_MS = 30000;
|
||||
|
||||
const post = async (path, body) => {
|
||||
if (!BASE || Date.now() < disabledUntil) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return await res.json();
|
||||
} catch {
|
||||
// Any failure — offline, timeout, 5xx — is silent by design. The operator
|
||||
// gets the deterministic answer; they should never see plumbing.
|
||||
disabledUntil = Date.now() + COOL_OFF_MS;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns { intentId, confidence, score, margin, alternatives } or null.
|
||||
export const routeQuestion = (text) => post('/route', { text });
|
||||
|
||||
// Documentation passages, verbatim with attribution. No generation step —
|
||||
// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
|
||||
// a paraphrase drift from what the doc actually says.
|
||||
export const askDocs = (text) => post('/ask', { text });
|
||||
|
||||
// A semantic near-miss must never open a create form. Write intents require
|
||||
// high confidence AND corroboration from the deterministic trigger, so the
|
||||
// worst case is that the operator types the phrase the regex already knows.
|
||||
export const isRouteTrustworthy = (routed) => {
|
||||
if (!routed?.intentId) return false;
|
||||
if (routed.isWrite) return routed.confidence === 'high';
|
||||
return routed.confidence === 'high' || routed.confidence === 'medium';
|
||||
};
|
||||
30
src/pages/nearle/assistant/repeatFlow.js
Normal file
30
src/pages/nearle/assistant/repeatFlow.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { findRecentRuns, describeDay } from './repeatRuns';
|
||||
import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a run ||============================== //
|
||||
//
|
||||
// One question: which day. Everything after it — resolving customers, the drift
|
||||
// check, re-quoting at today's tariff, the already-repeated guard — is a pass
|
||||
// the panel runs and narrates, exactly like the bulk file's locate/price phase.
|
||||
// None of it is a question, so none of it is a step.
|
||||
|
||||
export const REPEAT_STEPS = [
|
||||
{
|
||||
id: 'day',
|
||||
type: 'select',
|
||||
ask: 'Which day’s orders should I repeat?',
|
||||
options: async () => {
|
||||
const { runs } = await findRecentRuns();
|
||||
return runs.map((r) => ({
|
||||
value: r.day,
|
||||
label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
|
||||
record: r
|
||||
}));
|
||||
},
|
||||
apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
|
||||
}
|
||||
];
|
||||
|
||||
export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
|
||||
export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
|
||||
export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);
|
||||
244
src/pages/nearle/assistant/repeatRuns.js
Normal file
244
src/pages/nearle/assistant/repeatRuns.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getAdminCustomers } from 'pages/api/doormileApi';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { groupForBookingStatus } from 'utils/orderStatusGroups';
|
||||
import { scanBookings } from './intents';
|
||||
import { cityGateFor } from './orderActions';
|
||||
import { priceBulkRows } from './bulkOrderActions';
|
||||
|
||||
// ==============================|| Doormile AI — repeat a past day's run ||============================== //
|
||||
//
|
||||
// "I created ten orders yesterday; make the same ten today."
|
||||
//
|
||||
// This is the cheapest write in the assistant, and the reason is worth stating:
|
||||
// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
|
||||
// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
|
||||
// the parcels. Only the recipient's name and phone are missing, and those come
|
||||
// from the `appcustomerid` → /admin/customers join the assistant already does.
|
||||
//
|
||||
// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
|
||||
// dominates the bulk-file flow does not apply here at all.
|
||||
//
|
||||
// ---- A booking is a snapshot, not a template --------------------------------
|
||||
//
|
||||
// Which is why every row goes through a drift check before it can be repeated.
|
||||
// These are not hypothetical; all three were found in one live page of 36
|
||||
// bookings:
|
||||
//
|
||||
// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
|
||||
// and coordinates but no address key at all) — repeating it blind sends an
|
||||
// empty pickup address.
|
||||
// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
|
||||
// is 0, so the payload would claim tenant zero.
|
||||
// • a pickup pincode that was open when the order was placed may not be now,
|
||||
// and CityGate refuses at the middleware, before the handler runs.
|
||||
//
|
||||
// ---- Duplicate safety is INVERTED here --------------------------------------
|
||||
//
|
||||
// Everywhere else in this assistant, near-identical orders are an error to
|
||||
// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
|
||||
// that guard would misfire on every single run. The question that actually
|
||||
// matters is different: *has this run already been repeated today?* The
|
||||
// endpoint has no idempotency key, so it is answered by looking: today's own
|
||||
// bookings are fingerprinted and any row already present is set aside rather
|
||||
// than booked twice.
|
||||
|
||||
export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
|
||||
|
||||
// How far back a run can be recalled from. Beyond a week it stops being "the
|
||||
// usual round" and starts being archaeology.
|
||||
const LOOKBACK_DAYS = 7;
|
||||
|
||||
const dayOf = (value) => {
|
||||
const t = parseDoormileTimestamp(value);
|
||||
return t.isValid() ? t.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
// A row is only worth repeating if it was a real order. Cancelled ones are
|
||||
// excluded — repeating a cancellation is never what "same as yesterday" means.
|
||||
const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
|
||||
|
||||
// ---- which days have a run to repeat ---------------------------------------
|
||||
export const findRecentRuns = async () => {
|
||||
const scan = await scanBookings();
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const counts = new Map();
|
||||
|
||||
(scan.rows || []).forEach((b) => {
|
||||
if (!isRepeatable(b)) return;
|
||||
const day = dayOf(b.createdat);
|
||||
if (!day || day === today) return;
|
||||
if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
|
||||
counts.set(day, (counts.get(day) || 0) + 1);
|
||||
});
|
||||
|
||||
return {
|
||||
scan,
|
||||
runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
|
||||
};
|
||||
};
|
||||
|
||||
export const describeDay = (day) => {
|
||||
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||
if (day === yesterday) return 'Yesterday';
|
||||
return dayjs(day).format('ddd D MMM');
|
||||
};
|
||||
|
||||
// ---- one booking → one repeatable row ---------------------------------------
|
||||
//
|
||||
// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
|
||||
// several kitchens and tenants, and collapsing them onto one shared pickup
|
||||
// would silently re-address half the orders.
|
||||
const toRow = (booking, customer, index) => ({
|
||||
line: index + 1,
|
||||
source: booking.bookingno || `#${booking.bookingid}`,
|
||||
tenantid: booking.tenantid,
|
||||
customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
|
||||
customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
|
||||
deliveryaddress: booking.deliveryaddress || '',
|
||||
deliverypincode: String(booking.deliverypincode || ''),
|
||||
deliverycity: booking.deliverycity || '',
|
||||
deliverylatitude: booking.deliverylatitude,
|
||||
deliverylongitude: booking.deliverylongitude,
|
||||
service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
|
||||
// Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
|
||||
// so this is left for priceBulkRows to fill exactly as an unpriced bulk row
|
||||
// would be. Copying yesterday's number would silently bill an old tariff.
|
||||
finalprice: '',
|
||||
previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
|
||||
itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
|
||||
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
|
||||
quantity: Math.max(1, booking.parcels?.length || 1),
|
||||
notes: booking.notes || '',
|
||||
// Who ran this drop last time. Carried so the repeat can hand the new order
|
||||
// back to the same rider — they already know the door, the buzzer and the
|
||||
// customer. Null when yesterday's order was never assigned, which is a
|
||||
// normal case and simply means the copy stays pending.
|
||||
__previousMilerUserId: booking.assignedmileruserid ?? null,
|
||||
__pickup: {
|
||||
address: booking.pickupaddress,
|
||||
pincode: booking.pickuppincode,
|
||||
city: booking.pickupcity,
|
||||
latitude: booking.pickuplatitude,
|
||||
longitude: booking.pickuplongitude
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the drift check --------------------------------------------------------
|
||||
//
|
||||
// Returns a REASON, never a boolean — an operator dropping a row deserves to
|
||||
// know which field went stale.
|
||||
const driftReason = (row) => {
|
||||
if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
|
||||
if (!row.__pickup?.address) return 'the original booking carries no pickup address';
|
||||
if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
|
||||
if (!row.customer_name) return 'the customer record is gone, so there is no name';
|
||||
if (!row.deliveryaddress) return 'no delivery address on the original';
|
||||
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
|
||||
return 'the original has no delivery coordinates, so it could never be routed';
|
||||
}
|
||||
if (!cityGateFor(row.__pickup.pincode)) {
|
||||
return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// What makes two orders "the same order". It has to include the PARCEL, not
|
||||
// just the destination: a run to one address for one customer is a completely
|
||||
// normal thing to do twice in a day with different contents, and judging on
|
||||
// phone + address + pickup alone made three unrelated bookings to the same bus
|
||||
// stand look identical — the repeat then reported all of yesterday as already
|
||||
// created when only one unrelated order existed today.
|
||||
//
|
||||
// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
|
||||
// usable identity. Price is deliberately excluded: it is re-quoted at today's
|
||||
// tariff, so it differs by design on every legitimate repeat.
|
||||
const fingerprint = (row) =>
|
||||
[
|
||||
row.customer_phone,
|
||||
String(row.deliveryaddress || '').toLowerCase(),
|
||||
row.__pickup?.pincode || '',
|
||||
row.service_option,
|
||||
row.itemcategory,
|
||||
row.itemdescription,
|
||||
row.quantity
|
||||
].join('|');
|
||||
|
||||
// ---- assemble the run -------------------------------------------------------
|
||||
export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
|
||||
const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
|
||||
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
|
||||
|
||||
const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
|
||||
const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
|
||||
|
||||
// Already repeated today? Today's own bookings are put through the SAME
|
||||
// `toRow` shaping, so both sides of the comparison get identical defaults —
|
||||
// hand-rolling the today side is how the two drifted apart in the first place.
|
||||
//
|
||||
// Counts, not a Set. A Set answers "does anything today look like this", so a
|
||||
// single matching order suppressed EVERY row that shared its fingerprint —
|
||||
// one order created today wiped out all of yesterday's. A multiset answers
|
||||
// the question that was actually meant: how many of these already exist. Two
|
||||
// identical orders yesterday and one today means one still needs creating.
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const todayCounts = new Map();
|
||||
(scan.rows || [])
|
||||
.filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
|
||||
.forEach((b) => {
|
||||
const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
|
||||
todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const drifted = [];
|
||||
const already = [];
|
||||
const candidates = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
const reason = driftReason(row);
|
||||
if (reason) {
|
||||
drifted.push({ ...row, error: reason });
|
||||
return;
|
||||
}
|
||||
// Consume one match per already-existing order, so a second identical row
|
||||
// is still offered once the first has been accounted for.
|
||||
const key = fingerprint(row);
|
||||
const remaining = todayCounts.get(key) || 0;
|
||||
if (remaining > 0) {
|
||||
todayCounts.set(key, remaining - 1);
|
||||
already.push(row);
|
||||
return;
|
||||
}
|
||||
candidates.push(row);
|
||||
});
|
||||
|
||||
// Re-quote at today's tariff. Priced per row because a run can span tenants,
|
||||
// and a tenant's own pricing row is what decides the number.
|
||||
const priced = [];
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
if (shouldStop?.()) break;
|
||||
const row = candidates[i];
|
||||
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
|
||||
priced.push(out);
|
||||
}
|
||||
if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
|
||||
|
||||
const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
|
||||
const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
|
||||
|
||||
return {
|
||||
day,
|
||||
scan,
|
||||
total: rows.length,
|
||||
valid,
|
||||
drifted,
|
||||
already,
|
||||
unpriced,
|
||||
// Every price that moved since the original, so a tariff change is visible
|
||||
// rather than discovered on an invoice.
|
||||
changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
|
||||
};
|
||||
};
|
||||
@@ -1298,33 +1298,33 @@ const Clients1 = () => {
|
||||
{KPI_META.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const value = tenantStatusCounts[item.countKey] ?? 0;
|
||||
// 'total' has no matching entry in STATUS_TABS (there's no "All"
|
||||
// tab) — only the three status tiles switch tabs when clicked.
|
||||
const total = tenantStatusCounts.total ?? 0;
|
||||
const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey);
|
||||
const clickable = tabIdx !== -1;
|
||||
const share =
|
||||
item.countKey === 'total'
|
||||
? `${value} clients`
|
||||
: total > 0
|
||||
? `${Math.round((value / total) * 100)}% of ${total}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
<StatCard
|
||||
key={item.key}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
title={item.label}
|
||||
// "all tenants" dropped — the card is already titled, and the
|
||||
// count beside it says "N clients". `item.hint` is kept so any
|
||||
// card that defines its own gloss still shows it.
|
||||
range={item.hint}
|
||||
value={value ?? 0}
|
||||
meta={share}
|
||||
icon={<Icon size={20} />}
|
||||
color={item.color}
|
||||
loading={getalltenantsIsLoading}
|
||||
clickable={clickable}
|
||||
isSelected={clickable && value0 === tabIdx}
|
||||
onClick={clickable ? () => handleChange(tabIdx) : undefined}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleChange(tabIdx);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{ cursor: clickable ? 'pointer' : 'default', height: '100%' }}
|
||||
>
|
||||
<StatCard
|
||||
title={item.label}
|
||||
value={value ?? 0}
|
||||
icon={<Icon size={20} />}
|
||||
color={item.color}
|
||||
loading={getalltenantsIsLoading}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</KpiGrid>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { MdPersonAddAlt1 } from 'react-icons/md';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Card } from '@astryxdesign/core/Card';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
@@ -19,34 +18,32 @@ import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import { PageShell } from 'components/nearle_components/PageLayout';
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
||||
import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
|
||||
import { createAdminCustomer } from 'pages/api/doormileApi';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
import { AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
const BRAND = DT.brand;
|
||||
|
||||
// Same field set as nearle_console_express's createCustomer.js (Location,
|
||||
// Choose Client, Name, Phone, Email, Door No, Address, Location/City/State/
|
||||
// Postcode, Landmark) — but POST /admin/tenantcustomers only persists
|
||||
// { firstname, lastname, phone, email }. There is no address/coordinate
|
||||
// storage anywhere on the new API for a customer record (confirmed against
|
||||
// express-console-api.md — the old jupiter /customers/create body doesn't
|
||||
// exist here at all). The address section below is collected and shown so
|
||||
// the form matches the reference 1:1, but it is NOT sent on submit — see the
|
||||
// note under that section. `tenantid` isn't in the documented POST body
|
||||
// either; it's included speculatively for staff logins (every other create
|
||||
// endpoint on this API takes tenantid explicitly) and may be silently
|
||||
// ignored server-side — unconfirmed, this endpoint isn't in the "confirmed
|
||||
// end-to-end" list.
|
||||
// Writes to POST /admin/customers — the same resource the Customers page
|
||||
// (customers.js) reads via getAdminCustomers(), so a customer created here
|
||||
// shows up there immediately. This used to call createTenantCustomer
|
||||
// (POST /admin/tenantcustomers), a different store with its own id
|
||||
// sequence — a customer created that way never appeared on the Customers
|
||||
// list. See src/pages/nearle/assistant/CLAUDE.md ("Customer creation writes
|
||||
// to /admin/customers") for the full history; the Doormile AI assistant's
|
||||
// executeCreateCustomer was fixed first, this page now matches it.
|
||||
//
|
||||
// This resource carries no tenantid (confirmed against a real
|
||||
// GET /admin/customers response), so there's no tenant picker here — unlike
|
||||
// Tenants/Riders/etc, a customer isn't scoped to a client at creation time.
|
||||
// A real GET /admin/customers response does carry address, doorno, landmark,
|
||||
// suburb, city, state, postcode, latitude and longitude, so the address
|
||||
// section below is sent (empty fields dropped) rather than discarded.
|
||||
const Createcustomer = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loggedInTenantId = localStorage.getItem('tenantid');
|
||||
const isStaffLogin = !loggedInTenantId || loggedInTenantId === '0';
|
||||
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [locaName, setLocoName] = useState('Select Location');
|
||||
const [tid, setTid] = useState(isStaffLogin ? '' : loggedInTenantId);
|
||||
|
||||
const [firstname, setFirstname] = useState('');
|
||||
const [lastname, setLastname] = useState('');
|
||||
@@ -60,13 +57,6 @@ const Createcustomer = () => {
|
||||
const [appLocaLat, setAppLocaLat] = useState();
|
||||
const [appLocaLng, setAppLocaLng] = useState();
|
||||
|
||||
const { data: tenantList = [] } = useQuery({
|
||||
queryKey: ['admin-tenants-picker'],
|
||||
queryFn: getAdminTenants,
|
||||
enabled: isStaffLogin
|
||||
});
|
||||
const tenantOptions = (tenantList || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
|
||||
|
||||
// LocationAutocomplete only reports back applocationid/locationname — it
|
||||
// caches the full hub list (with lat/lng) in localStorage under a
|
||||
// tenant-scoped key (applocations_<tenantid>, see LocationAutocomplete.js)
|
||||
@@ -123,8 +113,13 @@ const Createcustomer = () => {
|
||||
const opentoast = (message) =>
|
||||
enqueueSnackbar(message, { variant: 'error', anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
// Empty fields are dropped rather than sent as noise — mirrors
|
||||
// buildCustomerPayload in assistant/actions.js, the other caller of this
|
||||
// same endpoint.
|
||||
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||||
|
||||
const createCustomerMutation = useMutation({
|
||||
mutationFn: (payload) => createTenantCustomer(payload),
|
||||
mutationFn: (payload) => createAdminCustomer(payload),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
enqueueSnackbar('Customer created successfully', {
|
||||
@@ -141,20 +136,28 @@ const Createcustomer = () => {
|
||||
});
|
||||
|
||||
const createprofile = () => {
|
||||
if (isStaffLogin && !tid) {
|
||||
opentoast('Select Tenant');
|
||||
} else if (!firstname) {
|
||||
if (!firstname) {
|
||||
opentoast('Enter Name');
|
||||
} else if (!phone || phone.length !== 10) {
|
||||
opentoast('Enter a valid 10-digit Mobile Number');
|
||||
} else {
|
||||
createCustomerMutation.mutate({
|
||||
firstname,
|
||||
lastname,
|
||||
phone,
|
||||
email,
|
||||
...(isStaffLogin && tid ? { tenantid: +tid } : {})
|
||||
});
|
||||
createCustomerMutation.mutate(
|
||||
clean({
|
||||
firstname,
|
||||
lastname,
|
||||
phone,
|
||||
email,
|
||||
address: pickCust.address,
|
||||
doorno,
|
||||
landmark,
|
||||
suburb: pickCust.suburb,
|
||||
city: pickCust.city,
|
||||
state: pickCust.state,
|
||||
postcode: pickCust.postcode,
|
||||
latitude: startPoint.latitude || undefined,
|
||||
longitude: startPoint.longitude || undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,16 +185,6 @@ const Createcustomer = () => {
|
||||
</Text>
|
||||
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
|
||||
</VStack>
|
||||
{isStaffLogin && (
|
||||
<Selector
|
||||
label="Choose Client"
|
||||
options={tenantOptions}
|
||||
value={tid ? String(tid) : ''}
|
||||
onChange={(v) => setTid(v || '')}
|
||||
placeholder="Choose Client"
|
||||
hasSearch
|
||||
/>
|
||||
)}
|
||||
<TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
|
||||
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
|
||||
<TextInput
|
||||
@@ -210,11 +203,6 @@ const Createcustomer = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
|
||||
The backend doesn't store an address against a customer yet — these fields aren't required and won't be saved
|
||||
on submit.
|
||||
</Text>
|
||||
|
||||
<AddressAutocomplete
|
||||
id="customer-address"
|
||||
label="Address"
|
||||
|
||||
@@ -56,17 +56,39 @@ import StatCard from 'components/nearle_components/StatCard';
|
||||
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
|
||||
import { getTenantCustomers, updateTenantCustomer } from 'pages/api/doormileApi';
|
||||
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
// ============================================================================
|
||||
// GET /admin/customers confirmed live: { appcustomerid, createdat, email,
|
||||
// name, phone, totalbookings } — no city/address/firstname field exists at
|
||||
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
|
||||
// there is no create/delete here by design ("Tenant-scoped through their
|
||||
// bookings" — a customer only exists once they've ordered through a tenant).
|
||||
// This page reads GET /admin/tenantcustomers, NOT /admin/customers.
|
||||
//
|
||||
// Why: /admin/customers cannot be written to. `POST /admin/customers` returns
|
||||
// **405 Method Not Allowed** (confirmed live) — the route exists and POST is
|
||||
// not among its methods. On that resource a customer appears only as a side
|
||||
// effect of a booking ("creates a Guest customer if unknown"). So every create
|
||||
// in this console — this page's own dialog, the sidebar's Create Customer page,
|
||||
// and the assistant — writes to /admin/tenantcustomers, and reading
|
||||
// /admin/customers here meant none of them ever showed up.
|
||||
//
|
||||
// The two stores have separate id sequences: a customer created via the
|
||||
// assistant came back as id 2 while the /admin/customers list ran to id 44.
|
||||
// That is why the edit dialog below had to move to updateTenantCustomer as
|
||||
// well — left on updateAdminCustomer it would PATCH /admin/customers/:id with
|
||||
// a tenant-customer id, which is a 404 at best and edits a DIFFERENT PERSON at
|
||||
// worst.
|
||||
//
|
||||
// A real GET /admin/customers response also disproves the note that used to
|
||||
// live here claiming no address/firstname field exists — it carries firstname,
|
||||
// lastname, address, doorno, landmark, suburb, city, state, postcode, latitude
|
||||
// and longitude (all empty on every live record).
|
||||
//
|
||||
// The accessors below deliberately read BOTH shapes. /admin/customers returns a
|
||||
// composed `name` and `appcustomerid`; tenant customers use firstname/lastname
|
||||
// and a different id field, and Tenants.js is not reliable evidence either (it
|
||||
// writes `phone` but renders `contactno`). Falling back across both means a
|
||||
// field-name difference costs one column, not a table of blanks.
|
||||
//
|
||||
// The edit dialog replicates nearle_console_express's customer edit dialog
|
||||
// field-for-field (Name, Contact, Address, Location, City, State, Postcode,
|
||||
@@ -78,10 +100,11 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
const BRAND = DT.brand;
|
||||
|
||||
const custId = (row) => row.appcustomerid;
|
||||
const custName = (row) => row.name || '—';
|
||||
const custPhone = (row) => row.phone || '—';
|
||||
const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.customerid ?? row.id;
|
||||
const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
|
||||
const custPhone = (row) => row.phone || row.contactno || '—';
|
||||
const custEmail = (row) => row.email || '—';
|
||||
// Tenant customers carry no booking count; 0 is honest rather than blank.
|
||||
const custBookings = (row) => row.totalbookings ?? 0;
|
||||
|
||||
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||
@@ -118,19 +141,26 @@ const Customers = () => {
|
||||
const [addressInput, setAddressInput] = useState('');
|
||||
const [pickAddress, setPickAddress] = useState({});
|
||||
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||
const [columnSizing, setColumnSizing] = useState({});
|
||||
|
||||
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
|
||||
const { data: customers = [], isLoading } = useQuery({ queryKey: ['tenant-customers'], queryFn: getTenantCustomers });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return customers;
|
||||
let list = customers;
|
||||
if (activeFilter === 'withEmail') {
|
||||
list = list.filter((c) => Boolean(c.email));
|
||||
} else if (activeFilter === 'withPhone') {
|
||||
list = list.filter((c) => Boolean(c.phone));
|
||||
}
|
||||
if (!debouncedSearch) return list;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return customers.filter((row) =>
|
||||
return list.filter((row) =>
|
||||
[custName(row), custPhone(row), custEmail(row)].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
|
||||
);
|
||||
}, [customers, debouncedSearch]);
|
||||
}, [customers, activeFilter, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = customers.length;
|
||||
@@ -144,11 +174,24 @@ const Customers = () => {
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateAdminCustomer(custId(editRow), form),
|
||||
mutationFn: () => {
|
||||
// Tenant customers store firstname/lastname, but this dialog edits one
|
||||
// "Customer Name" field. Split on the first space so a round-trip keeps
|
||||
// both parts instead of blanking the surname.
|
||||
const [firstname, ...rest] = String(form.name || '')
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
return updateTenantCustomer(custId(editRow), {
|
||||
firstname: firstname || '',
|
||||
lastname: rest.join(' '),
|
||||
phone: form.phone || '',
|
||||
email: form.email || ''
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Customer updated', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tenant-customers'] });
|
||||
setEditRow(null);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to update customer');
|
||||
@@ -159,10 +202,11 @@ const Customers = () => {
|
||||
|
||||
const openEdit = (row) => {
|
||||
setEditRow(row);
|
||||
// Seeded through the accessors so the dialog works on either record shape.
|
||||
setForm({
|
||||
name: row.name || '',
|
||||
phone: row.phone || '',
|
||||
email: row.email || ''
|
||||
name: custName(row) === '—' ? '' : custName(row),
|
||||
phone: custPhone(row) === '—' ? '' : custPhone(row),
|
||||
email: custEmail(row) === '—' ? '' : custEmail(row)
|
||||
});
|
||||
setAddressInput(row.address || '');
|
||||
setPickAddress({
|
||||
@@ -352,10 +396,43 @@ const Customers = () => {
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
<StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} />
|
||||
<StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" />
|
||||
<StatCard title="With Phone" value={stats.withPhone} icon={<MdOutlinePhoneAndroid size={20} />} color="#8b5cf6" />
|
||||
<StatCard title="Total Bookings" value={stats.totalBookings} icon={<MdOutlineReceiptLong size={20} />} color="#10b981" />
|
||||
<StatCard
|
||||
title="Total Customers"
|
||||
value={stats.total}
|
||||
meta={`${stats.total} customers`}
|
||||
icon={<MdOutlinePeopleAlt size={20} />}
|
||||
color={BRAND}
|
||||
clickable
|
||||
isSelected={activeFilter === 'all'}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<StatCard
|
||||
title="With Email"
|
||||
value={stats.withEmail}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.withEmail / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdOutlineMailOutline size={20} />}
|
||||
color="#0ea5e9"
|
||||
clickable
|
||||
isSelected={activeFilter === 'withEmail'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'withEmail' ? 'all' : 'withEmail'))}
|
||||
/>
|
||||
<StatCard
|
||||
title="With Phone"
|
||||
value={stats.withPhone}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.withPhone / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdOutlinePhoneAndroid size={20} />}
|
||||
color="#8b5cf6"
|
||||
clickable
|
||||
isSelected={activeFilter === 'withPhone'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'withPhone' ? 'all' : 'withPhone'))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Bookings"
|
||||
value={stats.totalBookings}
|
||||
meta={`${stats.totalBookings} bookings`}
|
||||
icon={<MdOutlineReceiptLong size={20} />}
|
||||
color="#10b981"
|
||||
/>
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -128,7 +128,7 @@ const ActiveSection = ({
|
||||
}
|
||||
// Rider delivered → reached customer location
|
||||
if (old.status === 'active' && cur.status === 'delivered') {
|
||||
OpenToast(`📍 ${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000);
|
||||
OpenToast(`${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,20 +9,30 @@ Rules for editing `Dispatch.js`, `Preview.js`, `CompareDataPanel.js`, and `dispa
|
||||
|
||||
Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors them — **the two pages must agree on which batch a given row belongs to**, otherwise the same delivery shows up in one batch on one page and a different batch on the other.
|
||||
|
||||
**The windows live in `utils/batchBucket.js` now.** Dispatch derives `BATCHES_DEFAULT_RAW` from it and `deliveries.js` derives `BATCH_OPTIONS` from it, so the two pages cannot disagree by construction. Only presentation (colour, icon) is page-local.
|
||||
|
||||
```js
|
||||
// BATCHES_DEFAULT_RAW — half-open [startHour, endHour) in LOCAL time, not UTC
|
||||
// utils/batchBucket.js — half-open [startHour, endHour) in LOCAL time, not UTC
|
||||
[
|
||||
{ id: 'morning', startHour: 0, endHour: 8 }, // 12 AM – 8 AM
|
||||
{ id: 'afternoon', startHour: 9, endHour: 12.5 }, // 9 AM – 12:30 PM
|
||||
{ id: 'evening', startHour: 16, endHour: 19 } // 4 PM – 7 PM
|
||||
{ id: 'morning', startHour: 0, endHour: 9 }, // 12 AM – 9 AM
|
||||
{ id: 'afternoon', startHour: 9, endHour: 16 }, // 9 AM – 4 PM
|
||||
{ id: 'evening', startHour: 16, endHour: 24 } // After 4 PM
|
||||
]
|
||||
```
|
||||
|
||||
**Gaps are intentional** (8–9 AM, 12 PM–4 PM, after 7 PM). Rows that fall in a gap belong to no batch — *not* to the nearest one.
|
||||
**They cover the whole day, and that is load-bearing.** They used to be 0–8 / 9–12.5 / 16–19 — 14.5 of 24 hours, with gaps at 8–9 AM, 12:30–4 PM and after 7 PM, documented here as intentional. They weren't wrong when they were written: they came from jupiter, where bucketing ran on `expecteddeliverytime` and they described **promised delivery slots**, which really did cluster.
|
||||
|
||||
This app buckets on `orderdate` — when the order was *placed* — and orders are placed all day. A booking created at 2:43 PM landed in the 12:30–4 PM gap, belonged to no batch, and disappeared from every batch filter on both pages. On Dispatch, where batches are the primary navigation, that made the order invisible entirely. 40% of the clock was a black hole.
|
||||
|
||||
Each window now runs to the start of the next. **Every start hour is unchanged, so the change is strictly additive** — verified exhaustively minute by minute: zero rows move between batches, 570 minutes that previously had no batch now have one, and no minute belongs to two.
|
||||
|
||||
If you ever move bucketing back to a promised-delivery field, the clustered windows become correct again — but then re-read the table below first, because that field was already tried and rejected for a different reason.
|
||||
|
||||
### Time-field selection (`BATCH_TIME_FIELD`)
|
||||
Fixed at `'created'` → bucket key is `['orderdate']` (the booking's `createdat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
|
||||
|
||||
Note the five `TIME_FIELDS` entries that are now inert: this backend emits only `createdat`, `updatedat` and the SLA estimate, so `acceptedtime` / `starttime` / `arrivaltime` / `pickuptime` / `deliverytime` are always undefined. They're harmless while `selectedTimeField` is a constant — but if the operator-facing dropdown is ever resurrected, five of its eight options would silently bucket nothing.
|
||||
|
||||
This is a constant, not state. The operator-facing time-field dropdown and the slot-hour editor have both been **deleted** (they were commented-out dead JSX whose setters nothing called). Don't resurrect either without re-reading the ban below.
|
||||
|
||||
**Three fields have been tried here. Two were wrong:**
|
||||
@@ -38,6 +48,20 @@ This is a constant, not state. The operator-facing time-field dropdown and the s
|
||||
|
||||
Those two key positions were previously destructured-but-ignored, which meant changing the date refetched and returned an identical row set — every batch count was the same for today, yesterday and last week. If you add a filter to this query, make sure `fetchDeliveries` actually reads it; a query key position that nothing consumes is a silent no-op.
|
||||
|
||||
#### ⛔ Don't admit rows on `updatedat` while bucketing on `createdat`
|
||||
|
||||
An opt-in "activity" date basis was added so an order booked yesterday and still moving today would appear on today's board, then **reverted**. Admitting a row on one timestamp while bucketing it on another cannot produce an honest batch: an order created yesterday at 6 PM and merely touched today was let onto today's board and then bucketed by its creation hour, so **Evening Batch read 6 at 10:41 in the morning on a day with no orders created at all**.
|
||||
|
||||
The filter field and the bucket field have to be the same field. If carried-over work needs to be visible on the board, it needs its own bucket ("Carried over") — not a time-of-day wave it does not belong to. Decide that before reaching for the date filter again.
|
||||
|
||||
### Picked is empty for hyperlocal traffic, and that is correct
|
||||
|
||||
`doormile-flow.md` §5: at `pickup-complete` the booking becomes a consignment, and **matching 3-digit pickup/delivery pincode prefixes go straight to `Out_for_Delivery`** (hyperlocal); everything else routes via a hub.
|
||||
|
||||
So a Coimbatore-to-Coimbatore order (641xxx → 641xxx) is picked up and is *immediately* out for delivery. It never rests in a "picked" state. The Picked tab reading 0 while orders are plainly moving is expected behaviour on hyperlocal traffic, not a missing mapping.
|
||||
|
||||
The confusing part is downstream of that: the booking's own `status` **freezes at `Converted_To_Consignment`** the moment it is picked up. The Deliveries page joins `GET /admin/consignments` and lets the consignment's status win, so a row correctly reads **Active** while `GET /admin/bookings` still says `Converted_To_Consignment`. That looks like a bug on inspection, which is why the status badge carries a tooltip naming the consignment and its status whenever the value came from there (`statusfromconsignment` on the row).
|
||||
|
||||
### ⛔ Never bucket on `assigntime`
|
||||
It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import {
|
||||
} from 'react-icons/md';
|
||||
|
||||
import { Grid } from '@astryxdesign/core/Grid';
|
||||
import { KpiGrid } from 'components/nearle_components/PageLayout';
|
||||
import { KpiGrid, TABLE_FRAME } from 'components/nearle_components/PageLayout';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
@@ -375,8 +375,15 @@ function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggl
|
||||
if (!isFocused && onFocus) onFocus(rider);
|
||||
};
|
||||
|
||||
// The resting frame matches every other table in the app. The focused state
|
||||
// keeps its accent outline — that is a selection affordance, not decoration,
|
||||
// and the brief was explicit about leaving selection alone.
|
||||
return (
|
||||
<Card padding={0} elevation="none" style={isFocused ? { outline: '2px solid var(--color-accent)' } : undefined}>
|
||||
<Card
|
||||
padding={0}
|
||||
elevation="none"
|
||||
style={{ border: TABLE_FRAME, ...(isFocused ? { outline: '2px solid var(--color-accent)' } : null) }}
|
||||
>
|
||||
<Collapsible
|
||||
isOpen={isExpanded}
|
||||
onOpenChange={handleOpenChange}
|
||||
@@ -485,11 +492,9 @@ export default function ProfitabilitySection({
|
||||
const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0);
|
||||
const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length;
|
||||
const lossCount = activeEnriched.length - profitCount;
|
||||
const totalOrders = enriched.reduce((s, r) => s + (r._m.orders?.length ?? 0), 0);
|
||||
|
||||
const dailyIsProfit = totalDailyProfit >= 0;
|
||||
const slotIsProfit = slotNet >= 0;
|
||||
const slotMargin = slotRevenue > 0 ? (slotNet / slotRevenue) * 100 : 0;
|
||||
|
||||
// Sorted loss-first so the riders needing attention are at the top.
|
||||
const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
|
||||
@@ -511,42 +516,36 @@ export default function ProfitabilitySection({
|
||||
value={rupees(Math.abs(totalDailyProfit))}
|
||||
icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
|
||||
color={moneyColor(dailyIsProfit)}
|
||||
caption="All slots today"
|
||||
/>
|
||||
<StatCard
|
||||
title="Riders active"
|
||||
value={activeEnriched.length}
|
||||
icon={<MdPeopleAlt size={20} />}
|
||||
color={STATUS.info}
|
||||
caption={`${profitCount} in profit · ${lossCount} at loss`}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total distance"
|
||||
value={`${slotKms.toFixed(1)} km`}
|
||||
icon={<MdStraighten size={20} />}
|
||||
color={STATUS.picked}
|
||||
caption="Cumulative travel distance"
|
||||
/>
|
||||
<StatCard
|
||||
title="Slot revenue"
|
||||
value={rupees(slotRevenue)}
|
||||
icon={<MdAccountBalanceWallet size={20} />}
|
||||
color={STATUS.success}
|
||||
caption={`From ${totalOrders} order${totalOrders !== 1 ? 's' : ''}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="Slot cost"
|
||||
value={rupees(slotCost)}
|
||||
icon={<MdPayments size={20} />}
|
||||
color={STATUS.error}
|
||||
caption="Fixed + variable"
|
||||
/>
|
||||
<StatCard
|
||||
title="Slot net"
|
||||
value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`}
|
||||
icon={<MdOutlineSavings size={20} />}
|
||||
color={moneyColor(slotIsProfit)}
|
||||
caption={`${slotMargin >= 0 ? '+' : ''}${slotMargin.toFixed(0)}% margin`}
|
||||
/>
|
||||
</KpiGrid>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
@@ -38,6 +39,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
@@ -189,29 +191,45 @@ const Exceptions = () => {
|
||||
{
|
||||
id: 'consignment',
|
||||
accessorKey: 'consignmentid',
|
||||
size: 170,
|
||||
minSize: 130,
|
||||
meta: { label: 'Consignment' },
|
||||
header: () => <SectionLabel>Consignment</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
|
||||
},
|
||||
{
|
||||
id: 'hub',
|
||||
accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'),
|
||||
size: 150,
|
||||
minSize: 120,
|
||||
meta: { label: 'Hub' },
|
||||
header: () => <SectionLabel>Hub</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||
size: 200,
|
||||
minSize: 150,
|
||||
meta: { label: 'Consignment & Hub' },
|
||||
header: () => <SectionLabel>Consignment & Hub</SectionLabel>,
|
||||
cell: ({ row }) => {
|
||||
const hub = hubMap.get(row.original.hubid)?.hubname || (row.original.hubid ? `Hub #${row.original.hubid}` : '—');
|
||||
return (
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Text weight="bold" style={{ fontSize: 13, color: DT.textPrimary }}>
|
||||
#{row.original.consignmentid}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
{hub}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
accessorKey: 'exceptiontype',
|
||||
size: 150,
|
||||
minSize: 120,
|
||||
meta: { label: 'Type' },
|
||||
header: () => <SectionLabel>Type</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||
size: 280,
|
||||
minSize: 200,
|
||||
meta: { label: 'Exception Details' },
|
||||
header: () => <SectionLabel>Exception Details</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
|
||||
{row.original.exceptiontype || 'Exception'}
|
||||
</Text>
|
||||
{row.original.description && (
|
||||
<Tooltip content={row.original.description}>
|
||||
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
|
||||
{row.original.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'severity',
|
||||
@@ -222,19 +240,6 @@ const Exceptions = () => {
|
||||
header: () => <SectionLabel>Severity</SectionLabel>,
|
||||
cell: ({ getValue }) => <Badge variant={SEVERITY_BADGE[getValue()] || 'neutral'} label={getValue()} />
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
accessorKey: 'description',
|
||||
size: 260,
|
||||
minSize: 180,
|
||||
meta: { label: 'Description' },
|
||||
header: () => <SectionLabel>Description</SectionLabel>,
|
||||
cell: ({ getValue }) => (
|
||||
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
|
||||
{getValue()}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (row) => row.status || 'Open',
|
||||
@@ -242,15 +247,15 @@ const Exceptions = () => {
|
||||
minSize: 110,
|
||||
meta: { label: 'Status' },
|
||||
header: () => <SectionLabel>Status</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||
cell: ({ getValue }) => <StatusBadge status={getValue()} />
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 140,
|
||||
minSize: 110,
|
||||
size: 90,
|
||||
minSize: 80,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
meta: { label: 'Actions' },
|
||||
meta: { label: 'Actions', align: 'right' },
|
||||
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||
cell: ({ row }) =>
|
||||
!['Resolved', 'Closed'].includes(row.original.status) ? (
|
||||
@@ -305,10 +310,34 @@ const Exceptions = () => {
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
|
||||
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
|
||||
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
|
||||
<StatCard title="Resolved" value={stats.resolved} icon={<MdCheckCircleOutline size={20} />} color="#10b981" />
|
||||
<StatCard
|
||||
title="Total"
|
||||
value={stats.total}
|
||||
meta={`${stats.total} logged`}
|
||||
icon={<MdWarningAmber size={20} />}
|
||||
color={BRAND}
|
||||
/>
|
||||
<StatCard
|
||||
title="Open"
|
||||
value={stats.open}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.open / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdWarningAmber size={20} />}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<StatCard
|
||||
title="Critical"
|
||||
value={stats.critical}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.critical / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdWarningAmber size={20} />}
|
||||
color="#ef4444"
|
||||
/>
|
||||
<StatCard
|
||||
title="Resolved"
|
||||
value={stats.resolved}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.resolved / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdCheckCircleOutline size={20} />}
|
||||
color="#10b981"
|
||||
/>
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
MdOutlineWarehouse,
|
||||
MdStorefront,
|
||||
MdEdit,
|
||||
MdDeleteOutline,
|
||||
MdAdd,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
createPaginatedRowModel
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
import { Grid } from '@astryxdesign/core/Grid';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
@@ -112,6 +114,7 @@ const Hubs = () => {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||
// /Tenants.js's slices.
|
||||
const [sorting, setSorting] = useState([]);
|
||||
@@ -121,12 +124,20 @@ const Hubs = () => {
|
||||
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return hubs;
|
||||
let list = hubs;
|
||||
if (activeFilter === 'sorting_center') {
|
||||
list = list.filter((h) => h.hubtype === 'sorting_center');
|
||||
} else if (activeFilter === 'delivery_hub') {
|
||||
list = list.filter((h) => h.hubtype === 'delivery_hub');
|
||||
} else if (activeFilter === 'active') {
|
||||
list = list.filter((h) => String(h.status || '').toLowerCase() === 'active');
|
||||
}
|
||||
if (!debouncedSearch) return list;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return hubs.filter((row) =>
|
||||
return list.filter((row) =>
|
||||
[row.hubname, row.hubtype, row.address, row.pincode].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
|
||||
);
|
||||
}, [hubs, debouncedSearch]);
|
||||
}, [hubs, activeFilter, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = hubs.length;
|
||||
@@ -239,47 +250,45 @@ const Hubs = () => {
|
||||
{
|
||||
id: 'hubname',
|
||||
accessorKey: 'hubname',
|
||||
size: 220,
|
||||
minSize: 160,
|
||||
meta: { label: 'Hub Name' },
|
||||
header: () => <SectionLabel>Hub Name</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
|
||||
},
|
||||
{
|
||||
id: 'hubtype',
|
||||
accessorFn: (row) => (row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'),
|
||||
size: 150,
|
||||
minSize: 120,
|
||||
meta: { label: 'Type' },
|
||||
header: () => <SectionLabel>Type</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue()
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
accessorKey: 'applocationid',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
meta: { label: 'City' },
|
||||
header: () => <SectionLabel>City</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() ?? '—'
|
||||
size: 240,
|
||||
minSize: 180,
|
||||
meta: { label: 'Hub' },
|
||||
header: () => <SectionLabel>Hub</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<AccentAvatar color={BRAND} size={28}>
|
||||
<MdStorefront size={14} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
|
||||
{row.original.hubname}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
{row.original.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'address',
|
||||
accessorKey: 'address',
|
||||
size: 240,
|
||||
minSize: 160,
|
||||
meta: { label: 'Address' },
|
||||
header: () => <SectionLabel>Address</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
},
|
||||
{
|
||||
id: 'pincode',
|
||||
accessorKey: 'pincode',
|
||||
size: 110,
|
||||
minSize: 90,
|
||||
meta: { label: 'Pincode' },
|
||||
header: () => <SectionLabel>Pincode</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
size: 320,
|
||||
minSize: 240,
|
||||
meta: { label: 'Address & Location' },
|
||||
header: () => <SectionLabel>Address & Location</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Tooltip content={row.original.address || ''}>
|
||||
<Text weight="medium" maxLines={1} style={{ fontSize: 12 }}>
|
||||
{row.original.address || '—'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
{[row.original.pincode, row.original.applocationid ? `City #${row.original.applocationid}` : null].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
@@ -292,8 +301,8 @@ const Hubs = () => {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
meta: { label: 'Actions', align: 'right' },
|
||||
@@ -320,7 +329,6 @@ const Hubs = () => {
|
||||
)
|
||||
}
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pagination.pageIndex, pagination.pageSize]
|
||||
);
|
||||
|
||||
@@ -355,10 +363,46 @@ const Hubs = () => {
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
|
||||
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} />
|
||||
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} />
|
||||
<StatCard title="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} />
|
||||
<StatCard
|
||||
title="Total Hubs"
|
||||
value={stats.total}
|
||||
meta={`${stats.total} hubs`}
|
||||
icon={<MdOutlineWarehouse size={20} />}
|
||||
color={BRAND}
|
||||
clickable
|
||||
isSelected={activeFilter === 'all'}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<StatCard
|
||||
title="Sorting Centers"
|
||||
value={stats.sorting}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.sorting / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdOutlineWarehouse size={20} />}
|
||||
color={STATUS.info}
|
||||
clickable
|
||||
isSelected={activeFilter === 'sorting_center'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'sorting_center' ? 'all' : 'sorting_center'))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Delivery Hubs"
|
||||
value={stats.delivery}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.delivery / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdPlace size={20} />}
|
||||
color={STATUS.delivered}
|
||||
clickable
|
||||
isSelected={activeFilter === 'delivery_hub'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'delivery_hub' ? 'all' : 'delivery_hub'))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Hubs"
|
||||
value={stats.active}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.active / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdOutlineCheckCircle size={20} />}
|
||||
color={STATUS.active}
|
||||
clickable
|
||||
isSelected={activeFilter === 'active'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'active' ? 'all' : 'active'))}
|
||||
/>
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Loader from 'components/Loader';
|
||||
import { MdCheck, MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
|
||||
import logo from 'assets/images/doormile-logo.png';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -8,71 +8,400 @@ import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import { setLoginUser } from 'store/reducers/loginUserSlice';
|
||||
import { markSessionStart } from 'utils/session';
|
||||
import { loginAdmin } from 'pages/api/doormileApi';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
|
||||
// Astryx design system — see themes/astryx.js for the Doormile brand theme
|
||||
// and CLAUDE.md's <!-- ASTRYX:START --> block for the CLI workflow.
|
||||
// NOTE: custom CSS (xstyle/stylex.create()) isn't wired up yet — see the
|
||||
// comment in config-overrides.js. Everything below uses Astryx component
|
||||
// props only; the brand gradient panel and the two logo images are plain
|
||||
// native elements with inline `style` for that reason.
|
||||
// Astryx design system — see themes/astryx.js for the Doormile theme.
|
||||
//
|
||||
// Two things about how this page is built:
|
||||
//
|
||||
// 1. The brand panel and the password toggle are plain elements with a scoped
|
||||
// stylesheet, not Astryx layout components. Astryx has no breakpoint props,
|
||||
// no gradient/decoration surface and no end-slot on TextInput, so those
|
||||
// three needs cannot be expressed through component props at all. The
|
||||
// stylesheet below is the same escape hatch `themes/dt/primitives.js`
|
||||
// (TableScroll) and `PageLayout.js` (PageShell) already use.
|
||||
//
|
||||
// 2. EVERY selector in that stylesheet is prefixed `.dm-login`. The brand red
|
||||
// lives on this page only — the console's own palette (black accent, slate
|
||||
// ink) is deliberately untouched, so nothing here leaks into the operator
|
||||
// pages. Do not lift these colours into themes/astryx.js.
|
||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||
import { Theme } from '@astryxdesign/core/theme';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Link } from '@astryxdesign/core/Link';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { doormileTheme } from 'themes/astryx';
|
||||
|
||||
const brandPanelStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '46%',
|
||||
height: '100vh',
|
||||
color: '#fff',
|
||||
padding: 48,
|
||||
background: `linear-gradient(150deg, ${DT.brand} 0%, #3A3A3A 100%)`
|
||||
};
|
||||
|
||||
const logoLockupStyle = { position: 'absolute', top: 48, left: 48, maxHeight: 60 };
|
||||
|
||||
const bulletDotStyle = {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.18)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0
|
||||
};
|
||||
|
||||
// doormile-logo.png is a white asset; recolour to brand red for this
|
||||
// white-background card (the brand-panel logo stays white as-is).
|
||||
const formLogoStyle = {
|
||||
maxHeight: 48,
|
||||
filter: 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
||||
};
|
||||
// Doormile brand red. Stated once; every rule below reads the custom property
|
||||
// so the two tones can never drift apart.
|
||||
const BRAND = '#C8152D';
|
||||
const BRAND_DEEP = '#A90F24';
|
||||
|
||||
const BULLETS = ['Real-time fleet visibility', 'AI-optimised dispatch routes', 'Tenant, pricing & invoice control'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoped stylesheet.
|
||||
//
|
||||
// Astryx exposes stable class hooks (`astryx-card`, `astryx-text-input`,
|
||||
// `astryx-button`, `astryx-field-label`, `astryx-link`) alongside its generated
|
||||
// StyleX classes, and a two-class descendant selector out-specifies them —
|
||||
// verified in the browser rather than assumed. That is what lets this restyle
|
||||
// real Astryx components instead of re-implementing them.
|
||||
// ---------------------------------------------------------------------------
|
||||
const LOGIN_CSS = `
|
||||
.dm-login {
|
||||
--dm-brand: ${BRAND};
|
||||
--dm-brand-deep: ${BRAND_DEEP};
|
||||
--dm-ring: rgba(200, 21, 45, 0.10);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Left brand panel --------------------------------------------------- */
|
||||
.dm-login__brand {
|
||||
flex: 0 0 44%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 56px;
|
||||
color: #fff;
|
||||
/* Depth without decoration: one light lift from the top-left, one shadow
|
||||
into the bottom-right, over a restrained two-stop brand ramp. The panel
|
||||
should read as lit, not as gradient-y. */
|
||||
background:
|
||||
radial-gradient(115% 85% at 10% 6%, rgba(255, 255, 255, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(95% 85% at 95% 100%, rgba(0, 0, 0, 0.22) 0%, transparent 62%),
|
||||
linear-gradient(155deg, var(--dm-brand) 0%, #B31228 55%, var(--dm-brand-deep) 100%);
|
||||
}
|
||||
|
||||
/* A single oversized arc, barely visible — the "logistics curve". One shape,
|
||||
two hairlines, no pattern library. */
|
||||
.dm-login__brand::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -240px;
|
||||
bottom: -280px;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 0 0 72px rgba(255, 255, 255, 0.035);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Faint route lines, masked so they fade out rather than tile edge to edge. */
|
||||
.dm-login__brand::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(118deg, rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 78px);
|
||||
-webkit-mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
||||
mask-image: radial-gradient(130% 100% at 85% 12%, #000 0%, transparent 68%);
|
||||
}
|
||||
|
||||
.dm-login__logo {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
left: 56px;
|
||||
height: 46px;
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dm-login__copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.dm-login__headline {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 34px;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dm-login__lede {
|
||||
margin: 18px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.dm-login__features {
|
||||
margin: 34px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dm-login__feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.dm-login__tick {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- Right form panel --------------------------------------------------- */
|
||||
.dm-login__form {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fb;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.dm-login__stack {
|
||||
width: 100%;
|
||||
max-width: 436px;
|
||||
}
|
||||
|
||||
.dm-login .astryx-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
/* Elevated, not floating. */
|
||||
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.dm-login__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 26px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
font-weight: 700;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.dm-login__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
/* ---- Fields ------------------------------------------------------------- */
|
||||
.dm-login .astryx-field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.dm-login .astryx-text-input {
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border-color: #d1d5db;
|
||||
background: #ffffff;
|
||||
padding-inline: 14px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.dm-login .astryx-text-input input {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* One focus treatment for the whole form. :focus-within so the ring follows
|
||||
the field, not the bare <input> that sits inside Astryx's bordered wrapper.
|
||||
|
||||
Plain two-class specificity is enough: the resting rule above already
|
||||
out-specifies Astryx's own StyleX rules on this element, and this selector
|
||||
is strictly more specific than that one. If a future Astryx version starts
|
||||
winning here, the escape hatch is StyleX's own repeated-:not(#id) idiom —
|
||||
but write it with IDs that simply never exist rather than copying StyleX's
|
||||
escaped-hash form, because a backslash inside THIS template literal is
|
||||
consumed by JS before the CSS parser sees it and the rule is then dropped
|
||||
silently. */
|
||||
.dm-login .astryx-text-input:focus-within {
|
||||
border-color: var(--dm-brand);
|
||||
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||
}
|
||||
|
||||
/* Chrome paints autofilled inputs pale blue, which is the "browser-looking"
|
||||
field the design brief calls out. An inset shadow the size of the box is the
|
||||
only way to repaint it. */
|
||||
.dm-login .astryx-text-input input:-webkit-autofill,
|
||||
.dm-login .astryx-text-input input:-webkit-autofill:hover,
|
||||
.dm-login .astryx-text-input input:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 40px #ffffff inset;
|
||||
box-shadow: 0 0 0 40px #ffffff inset;
|
||||
-webkit-text-fill-color: #101828;
|
||||
}
|
||||
|
||||
/* ---- Password visibility toggle ----------------------------------------- */
|
||||
.dm-login__pw {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Keep typed characters clear of the toggle. */
|
||||
.dm-login__pw .astryx-text-input {
|
||||
padding-right: 46px;
|
||||
}
|
||||
|
||||
.dm-login__pw-toggle {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 0;
|
||||
height: 48px;
|
||||
width: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #667085;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.dm-login__pw-toggle:hover {
|
||||
color: #344054;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.dm-login__pw-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||
color: var(--dm-brand);
|
||||
}
|
||||
|
||||
/* ---- Primary action ----------------------------------------------------- */
|
||||
.dm-login .astryx-button {
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: var(--dm-brand);
|
||||
border-color: var(--dm-brand);
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.dm-login .astryx-button:hover:not([disabled]):not([aria-disabled='true']) {
|
||||
background: var(--dm-brand-deep);
|
||||
border-color: var(--dm-brand-deep);
|
||||
}
|
||||
|
||||
.dm-login .astryx-button:active:not([disabled]):not([aria-disabled='true']) {
|
||||
transform: translateY(1px);
|
||||
background: #970d20;
|
||||
}
|
||||
|
||||
.dm-login .astryx-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--dm-ring);
|
||||
}
|
||||
|
||||
.dm-login .astryx-button[disabled],
|
||||
.dm-login .astryx-button[aria-disabled='true'] {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* ---- Footer ------------------------------------------------------------- */
|
||||
.dm-login__footer {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
font-size: 13px;
|
||||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.dm-login__footer .astryx-link {
|
||||
font-size: 13px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.dm-login__footer .astryx-link:hover {
|
||||
color: var(--dm-brand);
|
||||
}
|
||||
|
||||
/* ---- Tablet ------------------------------------------------------------- */
|
||||
@media (max-width: 1100px) {
|
||||
.dm-login__brand { flex-basis: 42%; padding: 40px; }
|
||||
.dm-login__logo { top: 40px; left: 40px; height: 40px; }
|
||||
.dm-login__headline { font-size: 28px; }
|
||||
.dm-login__lede { font-size: 15px; margin-top: 14px; }
|
||||
.dm-login__features { margin-top: 26px; gap: 13px; }
|
||||
}
|
||||
|
||||
/* ---- Mobile ------------------------------------------------------------- */
|
||||
/* The brand panel COLLAPSES to a logo strip rather than being hidden and the
|
||||
logo re-rendered above the card. That keeps exactly one <img> of the logo in
|
||||
the DOM at every breakpoint — a duplicate that is merely display:none is
|
||||
still a duplicate to a screen reader and to anyone reading the markup. */
|
||||
@media (max-width: 860px) {
|
||||
.dm-login { flex-direction: column; min-height: 100dvh; }
|
||||
|
||||
.dm-login__brand {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.dm-login__brand::before,
|
||||
.dm-login__brand::after { display: none; }
|
||||
|
||||
.dm-login__logo { position: static; height: 34px; }
|
||||
.dm-login__copy { display: none; }
|
||||
|
||||
.dm-login__form {
|
||||
flex: 1 1 auto;
|
||||
align-items: flex-start;
|
||||
padding: 32px 20px 40px;
|
||||
}
|
||||
.dm-login__title { font-size: 23px; }
|
||||
.dm-login__footer { margin-top: 22px; gap: 8px 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.dm-login__form { padding-inline: 16px; }
|
||||
}
|
||||
`;
|
||||
|
||||
const Login = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const emailRef = useRef(null);
|
||||
const passwordRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 'authname' is the AUTH_PRESENCE_KEY (utils/session.js) — App.js and the
|
||||
@@ -83,10 +412,24 @@ const Login = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Astryx's TextInput has no `autoComplete` prop and BaseProps declares only
|
||||
// `xstyle`, so an unknown prop would be dropped rather than forwarded. It
|
||||
// does forward `ref` to the real <input>, which is the supported way to
|
||||
// reach the element — password managers need these attributes to offer a
|
||||
// saved credential at all. Re-applied when the toggle swaps `type`.
|
||||
useEffect(() => {
|
||||
emailRef.current?.setAttribute('autocomplete', 'email');
|
||||
passwordRef.current?.setAttribute('autocomplete', 'current-password');
|
||||
}, [showPassword]);
|
||||
|
||||
const opentoast = (message, variant = 'error') => OpenToast(message, variant, 2000);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
// Guard against a second submit while the first is still in flight —
|
||||
// the button is disabled while loading, but Enter in a field is not.
|
||||
if (loading) return;
|
||||
|
||||
if (!email || !password) {
|
||||
opentoast('Enter your email and password');
|
||||
return;
|
||||
@@ -123,83 +466,105 @@ const Login = () => {
|
||||
return (
|
||||
<Theme theme={doormileTheme} mode="light">
|
||||
<AppShell contentPadding={0}>
|
||||
{loading && <Loader />}
|
||||
<HStack gap={0} height="100vh" wrap="nowrap">
|
||||
{/* ---- Left brand panel (plain element: see the note at the top of
|
||||
this file about custom styling not going through Astryx yet) ---- */}
|
||||
<div style={brandPanelStyle}>
|
||||
<img src={logo} alt="Doormile" style={logoLockupStyle} />
|
||||
<style>{LOGIN_CSS}</style>
|
||||
|
||||
<VStack gap={2} maxWidth={430}>
|
||||
<Heading level={1} color="inherit">
|
||||
Operate your dispatch, end to end.
|
||||
</Heading>
|
||||
<Text type="large" color="inherit">
|
||||
<div className="dm-login">
|
||||
{/* ---- Brand panel. The ONLY Doormile logo on this page. ---- */}
|
||||
<aside className="dm-login__brand">
|
||||
<img className="dm-login__logo" src={logo} alt="Doormile" />
|
||||
|
||||
<div className="dm-login__copy">
|
||||
<h1 className="dm-login__headline">Operate your dispatch, end to end.</h1>
|
||||
<p className="dm-login__lede">
|
||||
Orders, AI route optimisation, live rider tracking and billing — all in the Doormile Express operator console.
|
||||
</Text>
|
||||
|
||||
<VStack gap={1.5} padding={0}>
|
||||
</p>
|
||||
<ul className="dm-login__features">
|
||||
{BULLETS.map((t) => (
|
||||
<HStack key={t} gap={1.25} vAlign="center">
|
||||
<span style={bulletDotStyle}>✓</span>
|
||||
<Text color="inherit">{t}</Text>
|
||||
</HStack>
|
||||
<li key={t} className="dm-login__feature">
|
||||
<span className="dm-login__tick" aria-hidden="true">
|
||||
<MdCheck size={14} />
|
||||
</span>
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ---- Right form panel ---- */}
|
||||
<Center axis="both" width="54%" height="100vh">
|
||||
<VStack width="100%" maxWidth={440} gap={4} padding={3}>
|
||||
<Card padding={8} elevation="med">
|
||||
<VStack gap={6} padding={0}>
|
||||
<VStack gap={1.5} hAlign="center" padding={0}>
|
||||
<img src={logo} alt="Doormile" style={formLogoStyle} />
|
||||
<Heading level={2}>Welcome back</Heading>
|
||||
<Text type="supporting">Sign in to the Doormile Express console</Text>
|
||||
</VStack>
|
||||
{/* ---- Form panel ---- */}
|
||||
<main className="dm-login__form">
|
||||
<div className="dm-login__stack">
|
||||
<Card padding={8} elevation="none">
|
||||
<VStack gap={5} padding={0}>
|
||||
<div>
|
||||
<h2 className="dm-login__title">Welcome back</h2>
|
||||
<p className="dm-login__subtitle">Sign in to the Doormile Express console</p>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<form noValidate onSubmit={handleSubmit}>
|
||||
<VStack gap={4} padding={0}>
|
||||
<TextInput
|
||||
ref={emailRef}
|
||||
hasAutoFocus
|
||||
label="E-mail Address"
|
||||
label="Email address"
|
||||
type="email"
|
||||
size="lg"
|
||||
placeholder="you@company.com"
|
||||
value={email}
|
||||
isDisabled={loading}
|
||||
onChange={(value) => setEmail(value.toLocaleLowerCase())}
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
type="password"
|
||||
|
||||
<div className="dm-login__pw">
|
||||
<TextInput
|
||||
ref={passwordRef}
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
size="lg"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
isDisabled={loading}
|
||||
onChange={(value) => setPassword(value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dm-login__pw-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? <MdOutlineVisibilityOff size={19} /> : <MdOutlineVisibility size={19} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label={loading ? 'Signing in…' : 'Sign in'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
width="100%"
|
||||
isLoading={loading}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
<Button label="Sign in" type="submit" variant="primary" size="lg" width="100%" />
|
||||
</VStack>
|
||||
</form>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* footer */}
|
||||
<HStack justify="center" wrap="wrap" gap={2}>
|
||||
<Link href="https://doormile.com" target="_blank" isExternalLink type="supporting">
|
||||
© All rights reserved
|
||||
</Link>
|
||||
<div className="dm-login__footer">
|
||||
<span>© {new Date().getFullYear()} Doormile</span>
|
||||
<Link href="https://doormile.com/terms-of-service" target="_blank" isExternalLink type="supporting">
|
||||
Terms and Conditions
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
<Link href="https://doormile.com/privacy-policy" target="_blank" isExternalLink type="supporting">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Center>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AppShell>
|
||||
</Theme>
|
||||
);
|
||||
|
||||
263
src/pages/nearle/orders/CreateOrder.css
Normal file
263
src/pages/nearle/orders/CreateOrder.css
Normal file
@@ -0,0 +1,263 @@
|
||||
/* ==========================================================================
|
||||
Create Order — premium logistics layout
|
||||
--------------------------------------------------------------------------
|
||||
Restyles the existing Create Order structure into a Stripe/Linear-grade
|
||||
form: a real page header, a stage indicator, distinct Pickup → Drop journey
|
||||
cards, and a pricing card that reads as a quote rather than a form field.
|
||||
|
||||
Follows the same convention as OrdersRedesign.css / Dispatch.css — a
|
||||
page-scoped stylesheet imported by its own page — so hover, focus, sticky
|
||||
and breakpoint behaviour are all expressible, which inline styles can't do.
|
||||
|
||||
Every colour is a local `--co-*` token defined once here so the whole page
|
||||
moves together.
|
||||
========================================================================== */
|
||||
|
||||
.co-root {
|
||||
--co-brand: #d7193f;
|
||||
--co-pickup: #10b981;
|
||||
--co-drop: #f97316;
|
||||
|
||||
--co-text: #1f2937;
|
||||
--co-text-2: #6b7280;
|
||||
--co-text-3: #9ca3af;
|
||||
--co-border: #e5e7eb;
|
||||
--co-surface: #ffffff;
|
||||
--co-bg: #f8fafc;
|
||||
|
||||
--co-radius: 5px;
|
||||
--co-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Page header — informative, not tall
|
||||
-------------------------------------------------------------------------- */
|
||||
.co-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 0 0 16px;
|
||||
border-bottom: 1px solid var(--co-border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.co-root .co-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--co-text);
|
||||
}
|
||||
|
||||
.co-root .co-subtitle {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--co-text-2);
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Header picker — tenant / business-location control that sits opposite the
|
||||
title block. One fixed-width slot shared by every state (loading, error,
|
||||
badge, dropdown) so the control never changes size or drifts out of
|
||||
vertical alignment with the title as `locationsStatus` changes.
|
||||
-------------------------------------------------------------------------- */
|
||||
.co-header-picker {
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
flex: 0 1 auto;
|
||||
/* Pins the picker to the far right of the header row even when the row
|
||||
wraps — a single child on a wrapped flex line sits at flex-start under
|
||||
justify-content: space-between, so margin-left: auto is what actually
|
||||
keeps it flush right in that case. */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.co-root .co-header-picker-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--co-text-3);
|
||||
}
|
||||
|
||||
.co-root .co-header-picker-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--co-border);
|
||||
border-radius: var(--co-radius);
|
||||
background: var(--co-surface);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Cards — one subtle border, one very light shadow. No heavy chrome.
|
||||
-------------------------------------------------------------------------- */
|
||||
.co-root .co-card {
|
||||
border: 1px solid var(--co-border) !important;
|
||||
border-radius: var(--co-radius) !important;
|
||||
background: var(--co-surface);
|
||||
box-shadow: var(--co-shadow) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The end-accent strip is what makes Pickup and Drop distinguishable at a
|
||||
glance without colouring the whole card. */
|
||||
.co-root .co-card[data-end='pickup'] {
|
||||
border-top: 2px solid var(--co-pickup) !important;
|
||||
}
|
||||
|
||||
.co-root .co-card[data-end='drop'] {
|
||||
border-top: 2px solid var(--co-drop) !important;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Pricing — reads as a quote, not a form field
|
||||
-------------------------------------------------------------------------- */
|
||||
.co-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
font-size: 13px;
|
||||
color: var(--co-text-2);
|
||||
border-bottom: 1px dashed var(--co-border);
|
||||
}
|
||||
|
||||
.co-root .co-price-row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.co-root .co-price-row b {
|
||||
font-weight: 500;
|
||||
color: var(--co-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.co-price-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--co-border);
|
||||
}
|
||||
|
||||
.co-root .co-price-total-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--co-text);
|
||||
}
|
||||
|
||||
.co-root .co-price-total-value {
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--co-pickup);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.co-root .co-price-note {
|
||||
font-size: 11.5px;
|
||||
color: var(--co-text-3);
|
||||
}
|
||||
|
||||
/* Pickup/drop dot — shared by the order-summary legs below. */
|
||||
.co-journey-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: 4px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.co-root .co-journey-dot[data-end='pickup'] {
|
||||
background: var(--co-pickup);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-pickup) 18%, transparent);
|
||||
}
|
||||
|
||||
.co-root .co-journey-dot[data-end='drop'] {
|
||||
background: var(--co-drop);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-drop) 18%, transparent);
|
||||
}
|
||||
|
||||
.co-root .co-summary-leg {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.co-root .co-summary-leg-text {
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--co-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.co-root .co-summary-muted {
|
||||
font-size: 11.5px;
|
||||
color: var(--co-text-3);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Field polish — labels above, calm borders, a clear focus ring
|
||||
-------------------------------------------------------------------------- */
|
||||
.co-root input:focus-visible,
|
||||
.co-root textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--co-brand) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--co-brand) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Responsive
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (max-width: 640px) {
|
||||
.co-root .co-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.co-root .co-header {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.co-header-picker {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.co-root * {
|
||||
transition: 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);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
|
||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import HoverSocialCard from 'components/cards/statistics/HoverSocialCard';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } from '../../api/api';
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
@@ -561,17 +561,16 @@ const OrdersPreview = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Was four <HoverSocialCard>s — solid-colour tiles that were the only
|
||||
KPI design in the app not coming from StatCard, so this row looked
|
||||
nothing like the KPI row on the other fifteen pages. Same data,
|
||||
same semantic colours, shared tile. */}
|
||||
{aiMode == 1 && (
|
||||
<KpiGrid>
|
||||
<HoverSocialCard
|
||||
secondary={metaData?.total_orders}
|
||||
primary="Orders"
|
||||
percentage={<MdDirectionsBike />}
|
||||
color={STATUS.delivered}
|
||||
/>
|
||||
<HoverSocialCard secondary={metaData?.total_riders} primary="Riders" percentage={<MdDirectionsBike />} color={STATUS.pending} />
|
||||
<HoverSocialCard secondary={zoneData?.length} primary="Zones" percentage={<FaMapLocationDot />} color="#0ea5e9" />
|
||||
<HoverSocialCard secondary={zoneData?.length} primary="Kilometer" percentage={<FaMapLocationDot />} color={STATUS.cancelled} />
|
||||
<StatCard title="Orders" value={metaData?.total_orders} icon={<MdDirectionsBike size={20} />} color={STATUS.delivered} />
|
||||
<StatCard title="Riders" value={metaData?.total_riders} icon={<MdDirectionsBike size={20} />} color={STATUS.pending} />
|
||||
<StatCard title="Zones" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.info} />
|
||||
<StatCard title="Kilometer" value={zoneData?.length} icon={<FaMapLocationDot size={20} />} color={STATUS.cancelled} />
|
||||
</KpiGrid>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.total-charge-badge {
|
||||
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(101, 56, 122, 0.10) 100%);
|
||||
border: 1px solid rgba(101, 56, 122, 0.18);
|
||||
border-radius: 10px;
|
||||
border-radius: 5px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { calculateDrivingDistance, calculateTotalCharge } from '../../../utils/distance';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '../../../utils/distance';
|
||||
import './CreateOrder.css';
|
||||
import { FaPhoneAlt, FaUser } from 'react-icons/fa';
|
||||
import { GiDoorHandle } from 'react-icons/gi';
|
||||
import { FaLandmarkDome, FaLocationDot } from 'react-icons/fa6';
|
||||
@@ -112,6 +113,11 @@ const sortLocations = (locations) =>
|
||||
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
|
||||
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
|
||||
|
||||
// POST /admin/expressbooking's own service tiers. Kept in step with
|
||||
// assistant/orderActions.js's SERVICE_OPTIONS — these are the API's values,
|
||||
// not a UI-invented Standard/Express/Same-Day set.
|
||||
const SERVICE_OPTION_VALUES = ['Normal', 'Fast', 'Superfast'];
|
||||
|
||||
const Createorder1 = () => {
|
||||
const [inputValue2, setInputValue2] = useState('');
|
||||
const [inputValue3, setInputValue3] = useState('');
|
||||
@@ -122,7 +128,17 @@ const Createorder1 = () => {
|
||||
// reason a persistent, visible message instead.
|
||||
const [locationsStatus, setLocationsStatus] = useState('loading');
|
||||
const [collectionamt, setCollectionamt] = useState(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
// Delivery tier. Was hardcoded to 'Normal' in the submit payload — the
|
||||
// operator had no way to book a Fast or Superfast delivery from this page at
|
||||
// all. These three are the API's own service_option values (not the
|
||||
// Standard/Express/Same-Day set a generic form would offer).
|
||||
const [serviceOption, setServiceOption] = useState('Normal');
|
||||
// A parcel entry has NO quantity field (confirmed against
|
||||
// express-console-api.md) — "N items" is expressed as N entries, which is
|
||||
// also how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
|
||||
// So a package list replaces the single category/weight pair: each row
|
||||
// becomes its own parcel, and `count` expands to that many identical entries.
|
||||
const [packages, setPackages] = useState([{ category: '', weight: '', description: '', count: 1 }]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [open4, setOpen4] = useState(false);
|
||||
@@ -140,6 +156,8 @@ const Createorder1 = () => {
|
||||
const [pricePerKm, setPricePerKm] = useState(0);
|
||||
const [minKm, setMinKm] = useState(0);
|
||||
const [totalCharge, setTotalCharge] = useState(0);
|
||||
// Real routed minutes from OSRM; null when the Haversine fallback ran.
|
||||
const [durationMin, setDurationMin] = useState(null);
|
||||
const [category, setCategory] = useState(null);
|
||||
const [weight, setWeight] = useState('');
|
||||
// Per express-console-api.md's "Tenant scoping" section: token tenantid
|
||||
@@ -206,6 +224,10 @@ const Createorder1 = () => {
|
||||
try {
|
||||
const roundedDistance = await calculateDrivingDistance(pickup, drop);
|
||||
setDistance(roundedDistance);
|
||||
// Real routed minutes from OSRM. Null when the Haversine fallback ran —
|
||||
// a straight-line estimate has no honest duration, so nothing is shown
|
||||
// rather than a number derived from a guessed speed.
|
||||
setDurationMin(getLastRouteDurationMin());
|
||||
|
||||
const total = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm);
|
||||
setTotalCharge(total);
|
||||
@@ -308,7 +330,7 @@ const Createorder1 = () => {
|
||||
deliverycity: dropCust.city || '',
|
||||
deliverylatitude: Number(dropCust.latitude) || 0,
|
||||
deliverylongitude: Number(dropCust.longitude) || 0,
|
||||
service_option: 'Normal',
|
||||
service_option: serviceOption,
|
||||
finalprice: +totalCharge.toFixed(2) || 0,
|
||||
notes: otherinstructions || '',
|
||||
// The parcel object has no numeric quantity field (confirmed against
|
||||
@@ -318,11 +340,28 @@ const Createorder1 = () => {
|
||||
// This previously always sent exactly one parcel regardless of what the
|
||||
// operator typed into the Quantity field, so it silently got dropped —
|
||||
// matching "I entered 5, deliveries page shows 1".
|
||||
parcels: Array.from({ length: Math.max(1, Number(quantity) || 1) }, () => ({
|
||||
itemcategory: category || 'General',
|
||||
itemdescription: 'Order',
|
||||
declaredvalue: +totalCharge.toFixed(2) || 0
|
||||
}))
|
||||
// One entry per package, expanded by its count. Falls back to the legacy
|
||||
// single-category behaviour if the operator never touched the package
|
||||
// section, so an existing habit still works.
|
||||
parcels: packages.some((pkg) => pkg.category || pkg.description)
|
||||
? packages.flatMap((pkg) =>
|
||||
Array.from({ length: Math.max(1, Number(pkg.count) || 1) }, () => ({
|
||||
itemcategory: pkg.category || 'General',
|
||||
itemdescription: [pkg.description, pkg.weight].filter(Boolean).join(' · ') || 'Order',
|
||||
declaredvalue: +totalCharge.toFixed(2) || 0
|
||||
}))
|
||||
)
|
||||
: // Nothing entered in the package section — fall back to the single
|
||||
// legacy category chip and one parcel. The old free-text Quantity
|
||||
// field is gone; a package row's `count` is how N items is expressed
|
||||
// now, so there is no separate quantity state to read.
|
||||
[
|
||||
{
|
||||
itemcategory: category || 'General',
|
||||
itemdescription: 'Order',
|
||||
declaredvalue: +totalCharge.toFixed(2) || 0
|
||||
}
|
||||
]
|
||||
};
|
||||
if (!arr.tenantid) {
|
||||
opentoast('Pick a tenant before creating an order', 'warning', 2000);
|
||||
@@ -606,45 +645,37 @@ const Createorder1 = () => {
|
||||
<>
|
||||
{loading && <Loader />}
|
||||
|
||||
<PageShell>
|
||||
<Card padding={3} elevation="none" style={{ position: 'sticky', top: 'var(--appshell-header-height, 64px)', zIndex: 100 }}>
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<AccentAvatar color={BRAND} selected size={44}>
|
||||
<MdAddShoppingCart size={22} />
|
||||
<PageShell className="co-root">
|
||||
<VStack className="co-header" gap={0} padding={0}>
|
||||
<HStack justify="between" vAlign="center" gap={3} wrap="wrap" padding={0}>
|
||||
<HStack gap={2} vAlign="center" padding={0}>
|
||||
<AccentAvatar color={BRAND} selected size={40}>
|
||||
<MdAddShoppingCart size={20} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text type="heading" weight="bold">
|
||||
Create Order
|
||||
</Text>
|
||||
<HStack gap={1} vAlign="center">
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: '#10b981',
|
||||
boxShadow: '0 0 0 4px rgba(16,185,129,0.18)'
|
||||
}}
|
||||
/>
|
||||
<Text type="supporting" color="secondary">
|
||||
Fill pickup, drop, schedule & pricing details
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text className="co-title">Create Order</Text>
|
||||
<Text className="co-subtitle">Create a new delivery order by entering pickup, drop, schedule and pricing details.</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* One fixed-width, consistently-labelled slot for whichever
|
||||
picker state is active, so the control never jumps width or
|
||||
loses vertical alignment with the title block as it switches
|
||||
between loading / error / badge / dropdown. */}
|
||||
{locationsStatus === 'loading' ? (
|
||||
<Text color="secondary" weight="bold">
|
||||
Loading business locations…
|
||||
</Text>
|
||||
<div className="co-header-picker co-header-picker-status">
|
||||
<Text color="secondary" weight="bold">
|
||||
Loading business locations…
|
||||
</Text>
|
||||
</div>
|
||||
) : locationsStatus === 'no-tenant' && isStaffLogin ? (
|
||||
// Staff login (express-console-api.md: token tenantid 0/null
|
||||
// = "Doormile's own staff... sees everything, all tenants")
|
||||
// — pick which tenant to create this order for. Everything
|
||||
// downstream (pricing, saved locations, the booking's own
|
||||
// tenantid) is keyed off `tid`, which equals selectedTenantId.
|
||||
<div style={{ minWidth: 260 }}>
|
||||
<VStack className="co-header-picker" gap={0.5} padding={0}>
|
||||
<Text className="co-header-picker-label">Tenant</Text>
|
||||
<Selector
|
||||
label="Tenant"
|
||||
isLabelHidden
|
||||
@@ -655,23 +686,44 @@ const Createorder1 = () => {
|
||||
isLoading={staffTenantListLoading}
|
||||
hasSearch
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
) : locationsStatus === 'no-tenant' ? (
|
||||
<Text style={{ color: '#b91c1c' }} weight="bold">
|
||||
This login has no tenant assigned — sign in as a tenant user
|
||||
</Text>
|
||||
<div className="co-header-picker co-header-picker-status">
|
||||
<Text style={{ color: '#b91c1c' }} weight="bold">
|
||||
This login has no tenant assigned — sign in as a tenant user
|
||||
</Text>
|
||||
</div>
|
||||
) : locationsStatus === 'error' ? (
|
||||
<Text style={{ color: '#b91c1c', cursor: 'pointer' }} weight="bold" onClick={gettenantlocations}>
|
||||
Could not load business locations — tap to retry
|
||||
</Text>
|
||||
<div
|
||||
className="co-header-picker co-header-picker-status"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={gettenantlocations}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
gettenantlocations();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#b91c1c', cursor: 'pointer' }} weight="bold">
|
||||
Could not load business locations — tap to retry
|
||||
</Text>
|
||||
</div>
|
||||
) : locationsStatus === 'empty' ? (
|
||||
<Text style={{ color: '#b45309' }} weight="bold">
|
||||
No saved locations — enter pickup details manually below
|
||||
</Text>
|
||||
<div className="co-header-picker co-header-picker-status">
|
||||
<Text style={{ color: '#b45309' }} weight="bold">
|
||||
No saved locations — enter pickup details manually below
|
||||
</Text>
|
||||
</div>
|
||||
) : tenantLocations.length === 1 ? (
|
||||
<Badge variant="neutral" icon={<MdMyLocation size={14} />} label={locationDisplayName(tenantLocations[0])} />
|
||||
<VStack className="co-header-picker" gap={0.5} padding={0}>
|
||||
<Text className="co-header-picker-label">Business Location</Text>
|
||||
<Badge variant="neutral" icon={<MdMyLocation size={14} />} label={locationDisplayName(tenantLocations[0])} />
|
||||
</VStack>
|
||||
) : (
|
||||
<div style={{ minWidth: 300 }}>
|
||||
<VStack className="co-header-picker" gap={0.5} padding={0}>
|
||||
<Text className="co-header-picker-label">Business Location</Text>
|
||||
<Selector
|
||||
label="Business Location"
|
||||
isLabelHidden
|
||||
@@ -707,14 +759,14 @@ const Createorder1 = () => {
|
||||
hasSearch
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
)}
|
||||
</HStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<Grid columns={{ minWidth: 420, repeat: 'fit' }} gap={3}>
|
||||
{/* ================================================= || Pickup || ================================================= */}
|
||||
<Card padding={0} elevation="none">
|
||||
<Card padding={0} elevation="none" className="co-card" data-end="pickup">
|
||||
<SectionHeader
|
||||
color="#0ea5e9"
|
||||
icon={<MdLocationOn size={16} />}
|
||||
@@ -841,7 +893,7 @@ const Createorder1 = () => {
|
||||
</Card>
|
||||
|
||||
{/* ================================================= || Drop || ================================================= */}
|
||||
<Card padding={0} elevation="none">
|
||||
<Card padding={0} elevation="none" className="co-card" data-end="drop">
|
||||
<SectionHeader
|
||||
color={BRAND}
|
||||
icon={<MdLocalShipping size={16} />}
|
||||
@@ -968,7 +1020,7 @@ const Createorder1 = () => {
|
||||
</Card>
|
||||
|
||||
{/* ================================================= || Time || ================================================= */}
|
||||
<Card padding={0} elevation="none">
|
||||
<Card padding={0} elevation="none" className="co-card">
|
||||
<SectionHeader color="#f59e0b" icon={<MdSchedule size={16} />} title="Schedule" subtitle="Pickup date & time slot" />
|
||||
<VStack gap={2} padding={3}>
|
||||
<DateInput
|
||||
@@ -995,7 +1047,7 @@ const Createorder1 = () => {
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
borderRadius: 5,
|
||||
border: `1px solid ${DT.divider}`,
|
||||
background: DT.surfaceAlt,
|
||||
maxHeight: 150,
|
||||
@@ -1045,7 +1097,7 @@ const Createorder1 = () => {
|
||||
</Card>
|
||||
|
||||
{/* ================================================= || Distance & Pricing || ================================================= */}
|
||||
<Card padding={0} elevation="none">
|
||||
<Card padding={0} elevation="none" className="co-card">
|
||||
<SectionHeader
|
||||
color="#10b981"
|
||||
icon={<MdAttachMoney size={16} />}
|
||||
@@ -1053,11 +1105,37 @@ const Createorder1 = () => {
|
||||
subtitle="Auto-calculated from pickup → drop"
|
||||
/>
|
||||
<VStack gap={3} padding={3}>
|
||||
{showDistance && (
|
||||
<HStack justify="between">
|
||||
<Badge variant="blue" label={`Distance: ${distance} km`} />
|
||||
<Badge variant="blue" label={`Charge: ₹${totalCharge.toFixed(2)}`} />
|
||||
</HStack>
|
||||
{showDistance ? (
|
||||
<VStack gap={0} padding={0}>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Distance</span>
|
||||
<b>{distance} km</b>
|
||||
</HStack>
|
||||
{durationMin ? (
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Estimated duration</span>
|
||||
<b>{durationMin} min</b>
|
||||
</HStack>
|
||||
) : null}
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Base fare</span>
|
||||
<b>₹{Number(basePrice || 0).toFixed(2)}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>
|
||||
Distance charge
|
||||
{minKm ? ` · beyond ${minKm} km` : ''}
|
||||
</span>
|
||||
<b>₹{Math.max(0, Number(totalCharge || 0) - Number(basePrice || 0)).toFixed(2)}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-total" padding={0} gap={0}>
|
||||
<span className="co-price-total-label">Total amount</span>
|
||||
<span className="co-price-total-value">₹{Number(totalCharge || 0).toFixed(2)}</span>
|
||||
</HStack>
|
||||
<Text className="co-price-note">Auto-calculated from pickup → drop using this tenant’s pricing.</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<Text className="co-price-note">Enter both addresses and the distance and price are calculated automatically.</Text>
|
||||
)}
|
||||
|
||||
<VStack gap={1} padding={0}>
|
||||
@@ -1089,7 +1167,7 @@ const Createorder1 = () => {
|
||||
|
||||
<VStack gap={1} padding={0}>
|
||||
<Text weight="bold">Weight</Text>
|
||||
<HStack justify="between" gap={1} wrap="wrap" padding={2} style={{ border: '1px solid #eee', borderRadius: 8 }}>
|
||||
<HStack justify="between" gap={1} wrap="wrap" padding={2} style={{ border: '1px solid #eee', borderRadius: 5 }}>
|
||||
{WEIGHT_OPTIONS.map((w) => (
|
||||
<span
|
||||
key={w}
|
||||
@@ -1115,24 +1193,138 @@ const Createorder1 = () => {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* ============================== || Cash Collect & Quantity || ============================== */}
|
||||
<Card padding={0} elevation="none">
|
||||
{/* ============================== || Package details || ============================== */}
|
||||
<Card padding={0} elevation="none" className="co-card">
|
||||
<SectionHeader
|
||||
color="#6366f1"
|
||||
icon={<MdInventory2 size={16} />}
|
||||
title="Collection & Quantity"
|
||||
subtitle="Cash to collect on delivery, total items"
|
||||
title="Package Details"
|
||||
subtitle="What is being sent, and how much to collect"
|
||||
action={
|
||||
<Button
|
||||
label="Add another package"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPackages((prev) => [...prev, { category: '', weight: '', description: '', count: 1 }])}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<VStack gap={2} padding={3}>
|
||||
{packages.map((pkg, i) => (
|
||||
// Index keys are safe here: rows are only appended or removed,
|
||||
// never reordered, and each row is fully controlled.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<VStack key={i} className="co-package" gap={2} padding={0}>
|
||||
<HStack justify="between" vAlign="center" gap={2}>
|
||||
<Text className="co-package-label">Package {i + 1}</Text>
|
||||
{packages.length > 1 && (
|
||||
<Button
|
||||
label="Remove"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPackages((prev) => prev.filter((_, j) => j !== i))}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<Grid columns={{ minWidth: 170, repeat: 'fit' }} gap={2}>
|
||||
<Selector
|
||||
label="Category"
|
||||
options={PARCEL_CATEGORIES.map((c) => ({ value: c, label: c }))}
|
||||
value={pkg.category}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, category: v } : r)))}
|
||||
placeholder="Select category"
|
||||
/>
|
||||
<Selector
|
||||
label="Weight"
|
||||
options={WEIGHT_OPTIONS.map((w) => ({ value: w, label: w }))}
|
||||
value={pkg.weight}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, weight: v } : r)))}
|
||||
placeholder="Select weight"
|
||||
/>
|
||||
<TextInput
|
||||
label="Count"
|
||||
value={String(pkg.count)}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, count: v.replace(/\D/g, '') } : r)))}
|
||||
/>
|
||||
</Grid>
|
||||
<TextInput
|
||||
label="Description"
|
||||
value={pkg.description}
|
||||
onChange={(v) => setPackages((prev) => prev.map((r, j) => (j === i ? { ...r, description: v } : r)))}
|
||||
placeholder="2 meal boxes"
|
||||
/>
|
||||
</VStack>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={2}>
|
||||
<Selector
|
||||
label="Delivery type"
|
||||
options={SERVICE_OPTION_VALUES.map((v) => ({ value: v, label: v }))}
|
||||
value={serviceOption}
|
||||
onChange={setServiceOption}
|
||||
/>
|
||||
<TextInput label="Cash Collect" value={String(collectionamt)} onChange={(v) => setCollectionamt(v.replace(/[^\d.]/g, ''))} />
|
||||
<TextInput label="Quantity" value={String(quantity)} onChange={(v) => setQuantity(v.replace(/\D/g, ''))} />
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* ============================== || Order summary || ============================== */}
|
||||
{/* A normal full-width card in the single-column form flow (not a
|
||||
sidebar next to a taller column), so it stays static. It used to
|
||||
carry `position: sticky`, inherited from a two-column layout this
|
||||
page never actually has — with only one column, the card is the
|
||||
last block on the page, so sticking it caused a visible jump/
|
||||
collapse right as its own container's bottom edge scrolled into
|
||||
view. Reads straight off form state so it updates as the operator
|
||||
types — there is no second source of truth to drift from. */}
|
||||
<Card padding={0} elevation="none" className="co-card co-summary">
|
||||
<SectionHeader color="#0f172a" icon={<MdCheckCircle size={16} />} title="Order Summary" subtitle="Updates as you fill the form" />
|
||||
<VStack gap={2} padding={3}>
|
||||
<HStack className="co-summary-leg" padding={0} gap={0}>
|
||||
<span className="co-journey-dot" data-end="pickup" />
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text className="co-summary-muted">Pickup</Text>
|
||||
<Text className="co-summary-leg-text">{pickCust.address || '—'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack className="co-summary-leg" padding={0} gap={0}>
|
||||
<span className="co-journey-dot" data-end="drop" />
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text className="co-summary-muted">Drop</Text>
|
||||
<Text className="co-summary-leg-text">{dropCust.address || '—'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Divider />
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Schedule</span>
|
||||
<b>{selectedtime ? `${dayjs(startdate).format('DD MMM')} · ${selectedtime}` : '—'}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Packages</span>
|
||||
<b>
|
||||
{packages.reduce((n, pkg) => n + Math.max(1, Number(pkg.count) || 1), 0)}
|
||||
{packages[0]?.weight ? ` · ${packages[0].weight}` : ''}
|
||||
</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Service</span>
|
||||
<b>{serviceOption}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-row" padding={0} gap={0}>
|
||||
<span>Distance</span>
|
||||
<b>{showDistance ? `${distance} km${durationMin ? ` · ${durationMin} min` : ''}` : '—'}</b>
|
||||
</HStack>
|
||||
<HStack className="co-price-total" padding={0} gap={0}>
|
||||
<span className="co-price-total-label">Estimated total</span>
|
||||
<span className="co-price-total-value">{showDistance ? `₹${Number(totalCharge || 0).toFixed(2)}` : '—'}</span>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{/* ================================================= || Notes || ================================================= */}
|
||||
<Card padding={0} elevation="none">
|
||||
<Card padding={0} elevation="none" className="co-card">
|
||||
<SectionHeader
|
||||
color={DT.textSecondary}
|
||||
icon={<MdNotes size={16} />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { getAdminPricing, getAdminTenants, getTenantLocations, getAdminCustomers, createExpressBookingBulk } from 'pages/api/doormileApi';
|
||||
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
|
||||
import Papa from 'papaparse';
|
||||
import { SHEET_HEADER_MAP, requiredSheetColumns, normalizeHeader, cleanReceiverName } from 'utils/bulkOrderColumns';
|
||||
import * as XLSX from 'xlsx';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -48,7 +49,7 @@ import { FaLocationDot } from 'react-icons/fa6';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import { PageShell } from 'components/nearle_components/PageLayout';
|
||||
import { PageShell, TABLE_FRAME } from 'components/nearle_components/PageLayout';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
import { TableScroll } from 'themes/dt/primitives';
|
||||
import './OrdersRedesign.css';
|
||||
@@ -90,7 +91,7 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid #eef2f6',
|
||||
borderRadius: 10,
|
||||
borderRadius: 5,
|
||||
padding: '6px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -103,7 +104,7 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
borderRadius: 5,
|
||||
background: `${accent}18`,
|
||||
color: accent,
|
||||
display: 'flex',
|
||||
@@ -239,7 +240,7 @@ const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
|
||||
textAlign: 'left',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
borderRadius: 5,
|
||||
border: '1.5px solid #eef2f6',
|
||||
background: '#fff',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
@@ -250,7 +251,7 @@ const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
borderRadius: 5,
|
||||
background: `${accent}15`,
|
||||
color: accent,
|
||||
display: 'flex',
|
||||
@@ -606,30 +607,11 @@ const MultipleOrders = () => {
|
||||
};
|
||||
|
||||
// ============================== CSV / XLSX upload ==============================
|
||||
const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
|
||||
const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
const headerMap = {
|
||||
'pickupdate(yyyy-mmm-dd)': 'date',
|
||||
'sendername*': 'locationname',
|
||||
'senderphone*': 'locationcontact',
|
||||
'senderaddress*': 'locationaddress',
|
||||
'receivername*': 'firstname',
|
||||
receiverphone: 'contactno',
|
||||
'receiveralternatephone*': 'altcontactno',
|
||||
receiverfulladdress: 'address',
|
||||
// Best-effort — no live sample sheet to confirm these are the tenant's
|
||||
// actual column headers. Without them deliverycity/deliverypincode in
|
||||
// the submit payload were always sent blank; if these names are wrong,
|
||||
// behavior is unchanged from before (still blank), not worse.
|
||||
receivercity: 'city',
|
||||
receiverpincode: 'postcode',
|
||||
receiverlatitude: 'latitude',
|
||||
receiverlongitude: 'longitude',
|
||||
'itemdescription*': 'description',
|
||||
Quantity: 'quantity',
|
||||
' Collect Cash': 'collectionamt'
|
||||
};
|
||||
// The map, the header normaliser and the name cleaner now live in
|
||||
// utils/bulkOrderColumns.js so Doormile AI's file upload reads the exact same
|
||||
// sheet this page does. Behaviour here is unchanged — the definitions moved,
|
||||
// they were not rewritten.
|
||||
const headerMap = SHEET_HEADER_MAP;
|
||||
|
||||
const handleFileDirectUpload = (event) => {
|
||||
try {
|
||||
@@ -660,7 +642,7 @@ const MultipleOrders = () => {
|
||||
}
|
||||
return newRow;
|
||||
});
|
||||
const requiredCols = Object.keys(headerMap).filter((k) => k.trim().endsWith('*'));
|
||||
const requiredCols = requiredSheetColumns();
|
||||
const missingRequired = requiredCols.filter((clientCol) => !headers.includes(normalizeHeader(clientCol)));
|
||||
if (missingRequired.length > 0) {
|
||||
opentoast(`Missing columns: ${missingRequired.join(', ')}`, 'warning', 3000);
|
||||
@@ -1164,13 +1146,13 @@ const MultipleOrders = () => {
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
padding={2}
|
||||
style={{ border: '1px solid #eef2f6', borderInlineStart: '3px solid #0ea5e9', borderRadius: 10, background: '#fbfcff' }}
|
||||
style={{ border: '1px solid #eef2f6', borderInlineStart: '3px solid #0ea5e9', borderRadius: 5, background: '#fbfcff' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
borderRadius: 5,
|
||||
background: 'linear-gradient(135deg,#0ea5e9,#0284c7)',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
@@ -1268,7 +1250,10 @@ const MultipleOrders = () => {
|
||||
</VStack>
|
||||
|
||||
{/* RIGHT — file / drop preview */}
|
||||
<Card padding={2} elevation="none" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Same frame as every DataCard table — this preview holds a table but
|
||||
predates DataCard, so it states the shared constant rather than
|
||||
carrying a second definition of it. */}
|
||||
<Card padding={2} elevation="none" style={{ border: TABLE_FRAME, display: 'flex', flexDirection: 'column' }}>
|
||||
<HStack justify="between" vAlign="center" gap={1} wrap="wrap" style={{ marginBottom: 8 }}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="bold">{previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'}</Text>
|
||||
@@ -1312,7 +1297,7 @@ const MultipleOrders = () => {
|
||||
marginBottom: 8,
|
||||
background: 'rgba(245,158,11,0.06)',
|
||||
border: '1.5px solid rgba(245,158,11,0.25)',
|
||||
borderRadius: 10
|
||||
borderRadius: 5
|
||||
}}
|
||||
>
|
||||
<VStack gap={0} padding={0}>
|
||||
@@ -1591,7 +1576,7 @@ const MultipleOrders = () => {
|
||||
minHeight: 240,
|
||||
background: 'rgba(24,144,255,0.03)',
|
||||
border: '1px dashed rgba(24,144,255,0.25)',
|
||||
borderRadius: 12,
|
||||
borderRadius: 5,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -35,8 +35,6 @@ import {
|
||||
MdLocalShipping,
|
||||
MdClear,
|
||||
MdNotes,
|
||||
MdMyLocation,
|
||||
MdOutlineFlag,
|
||||
MdFlashOn,
|
||||
MdCancel,
|
||||
MdStraighten,
|
||||
@@ -63,9 +61,9 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import StatusTabs from 'components/nearle_components/StatusTabs';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
|
||||
@@ -299,6 +297,7 @@ PlaceCell.propTypes = {
|
||||
|
||||
const Orders = () => {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsNarrowViewport();
|
||||
const tid = localStorage.getItem('tenantid');
|
||||
const [tabstatus, setTabstatus] = useState('Pending');
|
||||
const [currentStatus, setCurrentStatus] = useState('pending_pickup');
|
||||
@@ -587,44 +586,63 @@ const Orders = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pickup',
|
||||
accessorKey: 'pickupaddress',
|
||||
size: 320,
|
||||
id: 'route',
|
||||
accessorFn: (row) => `${row.pickupaddress || ''} ${row.deliveryaddress || ''}`,
|
||||
size: 340,
|
||||
minSize: 260,
|
||||
meta: { label: 'Pickup' },
|
||||
header: () => <SectionLabel>Pickup</SectionLabel>,
|
||||
cell: ({ getValue, row }) => (
|
||||
<PlaceCell icon={<MdMyLocation size={11} />} primary={getValue() || '—'} secondary={row.original.pickuppincode || ''} />
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'drop',
|
||||
// The customer join lives in the accessor, so the cell only renders.
|
||||
accessorFn: (row) => {
|
||||
const customer = customerMap?.get(row.appcustomerid);
|
||||
return customer?.firstname || customer?.name || `Customer #${row.appcustomerid ?? '—'}`;
|
||||
},
|
||||
size: 360,
|
||||
minSize: 280,
|
||||
meta: { label: 'Drop' },
|
||||
header: () => <SectionLabel>Drop</SectionLabel>,
|
||||
cell: ({ getValue, row }) => {
|
||||
const customer = customerMap?.get(row.original.appcustomerid);
|
||||
meta: { label: 'Route (Pickup → Drop)' },
|
||||
header: () => <SectionLabel>Route (Pickup → Drop)</SectionLabel>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const customer = customerMap?.get(r.appcustomerid);
|
||||
const dropName = customer?.firstname || customer?.name || (r.appcustomerid ? `Customer #${r.appcustomerid}` : 'Drop Customer');
|
||||
const routeTooltip = (
|
||||
<div style={{ textAlign: 'left', minWidth: 200, maxWidth: 300, whiteSpace: 'normal', color: '#fff' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontWeight: 'bold', fontSize: '12px' }}>Pickup</span>
|
||||
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.pickupaddress || 'No address'} {r.pickuppincode ? `(${r.pickuppincode})` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<img src="/drop-icon.png" alt="Drop" style={{ width: 18, height: 18, marginTop: 2, flexShrink: 0 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontWeight: 'bold', fontSize: '12px' }}>Drop</span>
|
||||
<span style={{ fontSize: '11px', opacity: 0.9 }}>{dropName} {customer?.phone || customer?.contactno ? `(${customer.phone || customer.contactno})` : ''}</span>
|
||||
<span style={{ fontSize: '11px', opacity: 0.9 }}>{r.deliveryaddress || 'No address'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlaceCell icon={<MdOutlineFlag size={11} />} primary={getValue()} secondary={customer?.phone || customer?.contactno || ''}>
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<Text type="supporting" color="secondary" maxLines={1}>
|
||||
{row.original.deliveryaddress || '—'}
|
||||
</Text>
|
||||
{/* Parcel count moved out of its own 64px column — it was "1"
|
||||
on every row. It belongs next to the delivery it describes,
|
||||
and only shows when it is not 1, which is the one thing
|
||||
Astryx's Badge guidance says a badge IS for. */}
|
||||
{(row.original.parcels?.length || 0) > 1 && (
|
||||
<Badge variant="neutral" icon={<MdInventory2 size={11} />} label={`${row.original.parcels.length} parcels`} />
|
||||
)}
|
||||
</HStack>
|
||||
</PlaceCell>
|
||||
<Tooltip content={routeTooltip}>
|
||||
<VStack gap={0.5} padding={0} minWidth={0} style={{ cursor: 'pointer' }}>
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
|
||||
Pickup
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
|
||||
{r.pickupaddress ? `· ${r.pickupaddress}` : '—'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<img src="/drop-icon.png" alt="Drop" style={{ width: 17, height: 17, flexShrink: 0 }} />
|
||||
<Text weight="semibold" maxLines={1} style={{ fontSize: 12, flexShrink: 0 }}>
|
||||
{dropName}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
|
||||
{r.deliveryaddress ? `· ${r.deliveryaddress}` : ''}
|
||||
</Text>
|
||||
{(r.parcels?.length || 0) > 1 && (
|
||||
<Badge variant="neutral" icon={<MdInventory2 size={10} />} label={`${r.parcels.length} parcels`} />
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -928,52 +946,33 @@ const Orders = () => {
|
||||
viewport, at any window size, with no viewport arithmetic anywhere on
|
||||
this page. See PageLayout.js for why one `:has()` rule is needed to
|
||||
make that flex chain resolve. */}
|
||||
<PageShell fill>
|
||||
<PageShell>
|
||||
<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>
|
||||
{/* Status segment cards at the top of the page */}
|
||||
<StatusTabs
|
||||
value={currentStatus}
|
||||
onChange={handleChangetab}
|
||||
hasDivider={false}
|
||||
// The status names carry their own meaning to the people who live in
|
||||
// this page all day — "Pending" does not need "awaiting rider" spelled
|
||||
// out underneath it, and eight glossed labels in a row crowd the
|
||||
// number each card exists to show. Scoped to Orders: the hints stay
|
||||
// on the other list pages, which the same registry feeds.
|
||||
hideHint
|
||||
tabs={ORDERS_STATUS_TABS.map((t) => ({
|
||||
status: t.status,
|
||||
label: t.label,
|
||||
count: t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)
|
||||
}))}
|
||||
/>
|
||||
|
||||
<DataCard
|
||||
fill
|
||||
tabs={
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
|
||||
{/* The shared status strip, same component the other list pages
|
||||
use, so tab chrome — selected indicator, hover, focus ring,
|
||||
count badge, arrow-key navigation — is identical across the
|
||||
console instead of re-declared here. Labels stay this page's
|
||||
own ("Assigned", not the registry's "Accepted") because the
|
||||
Orders tabs track the OPERATOR's workflow. */}
|
||||
<StatusTabs
|
||||
value={currentStatus}
|
||||
onChange={handleChangetab}
|
||||
hasDivider={false}
|
||||
tabs={ORDERS_STATUS_TABS.map((t) => ({
|
||||
status: t.status,
|
||||
label: t.label,
|
||||
count: t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Search + Columns menu share the tab row's right side, so the
|
||||
whole toolbar — filter tabs, search, column visibility —
|
||||
reads as one control strip instead of two stacked rows. */}
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" style={{ width: '100%' }}>
|
||||
<Text weight="medium" color="secondary">
|
||||
{filteredRows.length} {filteredRows.length === 1 ? 'order' : 'orders'}
|
||||
</Text>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
|
||||
<DebounceSearchBar
|
||||
@@ -988,24 +987,26 @@ const Orders = () => {
|
||||
the tab uses for the checkbox column, so there is one
|
||||
source of truth for "is this column showing". `getCanHide()`
|
||||
is what keeps the checkbox column out of the list. */}
|
||||
<DropdownMenu
|
||||
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
|
||||
menuWidth={200}
|
||||
placement="below"
|
||||
>
|
||||
{table
|
||||
.getAllLeafColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
label={column.columnDef.meta?.label ?? column.id}
|
||||
value={column.getIsVisible()}
|
||||
onChange={(checked) => column.toggleVisibility(checked)}
|
||||
hasCloseOnSelect={false}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
{!isMobile && (
|
||||
<DropdownMenu
|
||||
button={{ label: 'Columns', variant: 'secondary', size: 'sm', icon: <MdViewColumn size={16} /> }}
|
||||
menuWidth={200}
|
||||
placement="below"
|
||||
>
|
||||
{table
|
||||
.getAllLeafColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
label={column.columnDef.meta?.label ?? column.id}
|
||||
value={column.getIsVisible()}
|
||||
onChange={(checked) => column.toggleVisibility(checked)}
|
||||
hasCloseOnSelect={false}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
}
|
||||
@@ -1050,7 +1051,13 @@ const Orders = () => {
|
||||
// readers announce the column's sort state from
|
||||
// here, not from the clickable content inside.
|
||||
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||
style={{ width: header.getSize(), minWidth: header.column.columnDef.minSize, maxWidth: header.column.columnDef.maxSize || header.getSize(), textAlign: align, position: 'relative' }}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
minWidth: header.column.columnDef.minSize,
|
||||
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||
textAlign: align,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
gap={1}
|
||||
@@ -1173,7 +1180,10 @@ const Orders = () => {
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{ textAlign: cell.column.columnDef.meta?.align, maxWidth: cell.column.columnDef.maxSize || cell.column.getSize() }}
|
||||
style={{
|
||||
textAlign: cell.column.columnDef.meta?.align,
|
||||
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
|
||||
@@ -12,10 +12,6 @@ import {
|
||||
MdCurrencyRupee,
|
||||
MdMap,
|
||||
MdNoteAlt,
|
||||
MdOutlineLocalShipping,
|
||||
MdOutlineCheckCircle,
|
||||
MdOutlinePendingActions,
|
||||
MdOutlineCancel,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
@@ -67,8 +63,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
|
||||
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { PageShell, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import StatusTabs from 'components/nearle_components/StatusTabs';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
@@ -341,10 +336,6 @@ export default function OrdersDetails() {
|
||||
});
|
||||
return c;
|
||||
}, [allRows]);
|
||||
const total = statusCountByKey.all;
|
||||
const pendingLenght = statusCountByKey.pending;
|
||||
const deliveredLenght = statusCountByKey.delivered;
|
||||
const cancelLenght = statusCountByKey.cancelled;
|
||||
|
||||
// The scroll-to-reveal sentinel + IntersectionObserver that used to live
|
||||
// here was already dead: the unconditional drain effect above (`if
|
||||
@@ -476,13 +467,6 @@ export default function OrdersDetails() {
|
||||
}
|
||||
}, [errormessage]);
|
||||
|
||||
const KPI_META = [
|
||||
{ key: 'total', label: 'Total Orders', color: BRAND, icon: MdOutlineLocalShipping, value: total },
|
||||
{ key: 'delivered', label: 'Delivered', color: STATUS.delivered, icon: MdOutlineCheckCircle, value: deliveredLenght },
|
||||
{ key: 'pending', label: 'Pending', color: STATUS.pending, icon: MdOutlinePendingActions, value: pendingLenght },
|
||||
{ key: 'cancelled', label: 'Cancelled', color: STATUS.cancelled, icon: MdOutlineCancel, value: cancelLenght }
|
||||
];
|
||||
|
||||
// A tab/zone/tenant/location/rider/date/search change re-scopes the list,
|
||||
// so page 1 is the only page that still means anything — same reset
|
||||
// orders.js/riders.js do on their own tables.
|
||||
@@ -839,7 +823,7 @@ export default function OrdersDetails() {
|
||||
Export. They used to be split across the header and a separate filter
|
||||
box below, which is why Export kept wrapping onto its own line and
|
||||
left that box half empty. */}
|
||||
<PageShell fill>
|
||||
<PageShell>
|
||||
{/* Only the primary action lives in the header. The zone picker and
|
||||
date range were up here too and the zone picker OVERLAPPED the date
|
||||
button — LocationAutocomplete brings its own width and ignores the
|
||||
@@ -853,25 +837,21 @@ export default function OrdersDetails() {
|
||||
action={<Button label="Export" variant="primary" icon={<MdFileDownload size={16} />} onClick={() => setReportDialog(true)} />}
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
{KPI_META.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<StatCard
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
value={item.value ?? 0}
|
||||
icon={<Icon size={20} />}
|
||||
color={item.color}
|
||||
loading={fetchDeliveriesIsLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</KpiGrid>
|
||||
{/* Status segment cards at the top of the page */}
|
||||
<StatusTabs
|
||||
value={currentStatus}
|
||||
onChange={setCurrentStatus}
|
||||
tabs={STATUS_TABS.map((key) => ({ status: key, count: statusCountByKey[key] ?? 0 }))}
|
||||
size="sm"
|
||||
hasDivider={false}
|
||||
// Same as the Orders strip: the status names stand on their own, and
|
||||
// nine glossed labels in a row bury the counts. Set here rather than
|
||||
// stripped from the shared registry, which still feeds Deliveries.
|
||||
hideHint
|
||||
/>
|
||||
|
||||
{/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */}
|
||||
<DataCard
|
||||
fill
|
||||
toolbar={
|
||||
<FilterToolbar max={5} minWidth={200}>
|
||||
<LocationAutocomplete
|
||||
@@ -963,14 +943,10 @@ export default function OrdersDetails() {
|
||||
</FilterToolbar>
|
||||
}
|
||||
tabs={
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
|
||||
<StatusTabs
|
||||
value={currentStatus}
|
||||
onChange={setCurrentStatus}
|
||||
tabs={STATUS_TABS.map((key) => ({ status: key, count: statusCountByKey[key] ?? 0 }))}
|
||||
size="sm"
|
||||
hasDivider={false}
|
||||
/>
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" style={{ width: '100%' }}>
|
||||
<Text weight="medium" color="secondary">
|
||||
{rows.length} {rows.length === 1 ? 'order' : 'orders'}
|
||||
</Text>
|
||||
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
|
||||
<DebounceSearchBar
|
||||
value={searchword}
|
||||
@@ -1366,7 +1342,7 @@ export default function OrdersDetails() {
|
||||
justify="between"
|
||||
vAlign="center"
|
||||
padding={2}
|
||||
style={{ borderRadius: 8, backgroundColor: tint(item.color), border: `1px solid ${edge(item.color)}` }}
|
||||
style={{ borderRadius: 5, backgroundColor: tint(item.color), border: `1px solid ${edge(item.color)}` }}
|
||||
>
|
||||
<Text weight="semibold">{item.label}</Text>
|
||||
<Badge variant={item.value ? 'neutral' : 'neutral'} label={item.value || 'None'} />
|
||||
|
||||
@@ -311,9 +311,9 @@ export default function OrdersReport() {
|
||||
if (isErrorReports) console.warn('ordersSummary error:', reportsError?.message);
|
||||
|
||||
const KPI_META = [
|
||||
{ key: 'orders', label: 'Total Orders', color: BRAND, icon: MdOutlineLocalShipping, value: stats.totalOrders },
|
||||
{ key: 'pending', label: 'Orders Pending', color: '#f59e0b', icon: MdOutlinePendingActions, value: stats.orderPend },
|
||||
{ key: 'completed', label: 'Orders Completed', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.orderComplete },
|
||||
{ key: 'orders', label: 'Total Orders', color: BRAND, icon: MdOutlineLocalShipping, value: stats.totalOrders, meta: `${stats.totalOrders} orders` },
|
||||
{ key: 'pending', label: 'Orders Pending', color: '#f59e0b', icon: MdOutlinePendingActions, value: stats.orderPend, meta: stats.totalOrders > 0 ? `${Math.round((stats.orderPend / stats.totalOrders) * 100)}% of total` : null },
|
||||
{ key: 'completed', label: 'Orders Completed', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.orderComplete, meta: stats.totalOrders > 0 ? `${Math.round((stats.orderComplete / stats.totalOrders) * 100)}% of total` : null },
|
||||
{ key: 'amount', label: 'Total Amount', color: '#0ea5e9', icon: MdOutlineCurrencyRupee, value: formatNumberToRupees(stats.amount) }
|
||||
];
|
||||
|
||||
@@ -674,7 +674,9 @@ export default function OrdersReport() {
|
||||
<StatCard
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
range={item.range}
|
||||
value={item.value ?? 0}
|
||||
meta={item.meta}
|
||||
icon={<Icon size={20} />}
|
||||
color={item.color}
|
||||
loading={isLoadingReports}
|
||||
|
||||
@@ -256,9 +256,9 @@ export default function RidersSummary() {
|
||||
}, [rows]);
|
||||
|
||||
const KPI_META = [
|
||||
{ key: 'riders', label: 'Active Riders', color: BRAND, icon: MdDirectionsBike, value: stats.riders },
|
||||
{ key: 'orders', label: 'Total Orders', color: '#0ea5e9', icon: MdOutlineLocalShipping, value: stats.orders },
|
||||
{ key: 'delivered', label: 'Delivered', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.delivered },
|
||||
{ key: 'riders', label: 'Active Riders', color: BRAND, icon: MdDirectionsBike, value: stats.riders, meta: `${stats.riders} riders` },
|
||||
{ key: 'orders', label: 'Total Orders', color: '#0ea5e9', icon: MdOutlineLocalShipping, value: stats.orders, meta: `${stats.orders} orders` },
|
||||
{ key: 'delivered', label: 'Delivered', color: '#10b981', icon: MdOutlineCheckCircle, value: stats.delivered, meta: stats.orders > 0 ? `${Math.round((stats.delivered / stats.orders) * 100)}% of orders` : null },
|
||||
{ key: 'amount', label: 'Total Amount', color: '#f59e0b', icon: MdOutlineCurrencyRupee, value: formatNumberToRupees(total) }
|
||||
];
|
||||
|
||||
@@ -523,7 +523,9 @@ export default function RidersSummary() {
|
||||
<StatCard
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
range={item.range}
|
||||
value={item.value ?? 0}
|
||||
meta={item.meta}
|
||||
icon={<Icon size={20} />}
|
||||
color={item.color}
|
||||
loading={isLoadingReports}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
MdArrowForward,
|
||||
MdDelete,
|
||||
MdBlock,
|
||||
MdDirectionsBike,
|
||||
MdDirectionsCar
|
||||
} from 'react-icons/md';
|
||||
|
||||
@@ -157,7 +158,7 @@ const LogChip = ({ color, icon: Icon, label, value }) => (
|
||||
gap={1.5}
|
||||
vAlign="center"
|
||||
padding={2}
|
||||
style={{ borderRadius: 12, background: '#fff', border: `1px solid ${edge(color)}`, minWidth: 0 }}
|
||||
style={{ borderRadius: 5, background: '#fff', border: `1px solid ${edge(color)}`, minWidth: 0 }}
|
||||
>
|
||||
<AccentAvatar color={color} size={28}>
|
||||
<Icon size={14} />
|
||||
@@ -591,19 +592,24 @@ const Riders = () => {
|
||||
{
|
||||
id: 'rider',
|
||||
accessorFn: (row) => row.displayname || row.authname || '—',
|
||||
size: 200,
|
||||
minSize: 160,
|
||||
size: 220,
|
||||
minSize: 180,
|
||||
meta: { label: 'Rider' },
|
||||
header: () => <SectionLabel>Rider</SectionLabel>,
|
||||
cell: ({ getValue, row }) => (
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||
{getValue()}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary">
|
||||
{row.original.phone || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<AccentAvatar color="#8b5cf6" size={28}>
|
||||
<MdDirectionsBike size={14} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
|
||||
{getValue()}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
{row.original.phone || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -615,13 +621,15 @@ const Riders = () => {
|
||||
header: () => <SectionLabel>Address</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<Tooltip content={row.original.address || ''}>
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text type="supporting" weight="bold" color="secondary" maxLines={1}>
|
||||
{row.original.suburb || (row.original.address ? row.original.address.slice(0, 20) + '…' : '—')}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" maxLines={1}>
|
||||
{row.original.city || ''}
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text weight="medium" maxLines={1} style={{ fontSize: 12 }}>
|
||||
{row.original.suburb || row.original.address || '—'}
|
||||
</Text>
|
||||
{row.original.city && (
|
||||
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 11 }}>
|
||||
{row.original.city}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -772,11 +780,23 @@ const Riders = () => {
|
||||
<KpiGrid>
|
||||
{KPI_META(allRidersSummary).map((item) => {
|
||||
const Icon = item.icon;
|
||||
const total = allRidersSummary?.total ?? 0;
|
||||
const share =
|
||||
item.key === 'total'
|
||||
? `${item.value} riders`
|
||||
: total > 0
|
||||
? `${Math.round((item.value / total) * 100)}% of ${total}`
|
||||
: null;
|
||||
// Gloss lines dropped, matching the Orders KPI strip: "Active"
|
||||
// does not need "on duty & free" underneath it for people who run
|
||||
// this page all day, and the second line competes with the number
|
||||
// the card exists to show.
|
||||
return (
|
||||
<StatCard
|
||||
key={item.key}
|
||||
title={item.label}
|
||||
value={item.value ?? 0}
|
||||
meta={share}
|
||||
icon={<Icon size={20} />}
|
||||
color={item.color}
|
||||
loading={riderSummarysLoading}
|
||||
|
||||
@@ -45,6 +45,7 @@ import Loader from 'components/Loader';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import {
|
||||
getTripsheets,
|
||||
@@ -207,35 +208,41 @@ const Tripsheets = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'sourceHub',
|
||||
accessorFn: (row) => hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—'),
|
||||
size: 220,
|
||||
minSize: 160,
|
||||
meta: { label: 'Source Hub' },
|
||||
header: () => <SectionLabel>Source Hub</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
|
||||
},
|
||||
{
|
||||
id: 'destinationHub',
|
||||
accessorFn: (row) => hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—'),
|
||||
size: 220,
|
||||
minSize: 160,
|
||||
meta: { label: 'Destination Hub' },
|
||||
header: () => <SectionLabel>Destination Hub</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
|
||||
id: 'route',
|
||||
accessorFn: (row) => `${hubMap.get(row.sourcehubid)?.hubname || ''} ${hubMap.get(row.destinationhubid)?.hubname || ''}`,
|
||||
size: 320,
|
||||
minSize: 240,
|
||||
meta: { label: 'Route (Source → Destination)' },
|
||||
header: () => <SectionLabel>Route (Source → Destination)</SectionLabel>,
|
||||
cell: ({ row }) => {
|
||||
const src = hubMap.get(row.original.sourcehubid)?.hubname || (row.original.sourcehubid ? `Hub #${row.original.sourcehubid}` : '—');
|
||||
const dest = hubMap.get(row.original.destinationhubid)?.hubname || (row.original.destinationhubid ? `Hub #${row.original.destinationhubid}` : '—');
|
||||
return (
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<img src="/pickup-icon.png" alt="Pickup" style={{ width: 20, height: 20, flexShrink: 0 }} />
|
||||
<Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
|
||||
{src}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<img src="/drop-icon.png" alt="Drop" style={{ width: 20, height: 20, flexShrink: 0 }} />
|
||||
<Text type="supporting" color="secondary" maxLines={1} style={{ fontSize: 12 }}>
|
||||
{dest}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
size: 160,
|
||||
minSize: 120,
|
||||
size: 140,
|
||||
minSize: 110,
|
||||
meta: { label: 'Status' },
|
||||
header: () => <SectionLabel>Status</SectionLabel>,
|
||||
cell: ({ getValue }) => (
|
||||
<Text type="supporting" color="secondary">
|
||||
{getValue() || '—'}
|
||||
</Text>
|
||||
)
|
||||
cell: ({ getValue }) => <StatusBadge status={getValue()} />
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MdAdd,
|
||||
MdBatteryStd,
|
||||
MdBatteryFull,
|
||||
MdBatteryChargingFull,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
@@ -45,15 +46,16 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
||||
import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { DT, STATUS } from 'themes/dt/tokens';
|
||||
import { TableScroll } from 'themes/dt/primitives';
|
||||
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
||||
|
||||
const BRAND = DT.brand;
|
||||
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
|
||||
@@ -103,6 +105,7 @@ const Vehicles = () => {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||
// slices.
|
||||
const [sorting, setSorting] = useState([]);
|
||||
@@ -114,12 +117,20 @@ const Vehicles = () => {
|
||||
const partnerMap = useMemo(() => new Map((partners || []).map((p) => [p.partnerid, p])), [partners]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return vehicles;
|
||||
let list = vehicles;
|
||||
if (activeFilter === 'available') {
|
||||
list = list.filter((v) => String(v.status).toLowerCase() === 'available');
|
||||
} else if (activeFilter === 'lowBattery') {
|
||||
list = list.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20);
|
||||
} else if (activeFilter === 'fullBattery') {
|
||||
list = list.filter((v) => Number(v.batterypercentage) >= 80);
|
||||
}
|
||||
if (!debouncedSearch) return list;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return vehicles.filter((row) =>
|
||||
return list.filter((row) =>
|
||||
[row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
|
||||
);
|
||||
}, [vehicles, debouncedSearch]);
|
||||
}, [vehicles, activeFilter, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = vehicles.length;
|
||||
@@ -234,20 +245,25 @@ const Vehicles = () => {
|
||||
{
|
||||
id: 'vehicleno',
|
||||
accessorKey: 'vehicleno',
|
||||
size: 160,
|
||||
minSize: 130,
|
||||
meta: { label: 'Vehicle No' },
|
||||
header: () => <SectionLabel>Vehicle No</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
|
||||
},
|
||||
{
|
||||
id: 'vehicletype',
|
||||
accessorKey: 'vehicletype',
|
||||
size: 130,
|
||||
minSize: 100,
|
||||
meta: { label: 'Type' },
|
||||
header: () => <SectionLabel>Type</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
size: 200,
|
||||
minSize: 160,
|
||||
meta: { label: 'Vehicle' },
|
||||
header: () => <SectionLabel>Vehicle</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<AccentAvatar color={BRAND} size={28}>
|
||||
<MdDirectionsCar size={14} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1} style={{ fontSize: 13, color: DT.textPrimary }}>
|
||||
{row.original.vehicleno}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
{row.original.vehicletype || 'Vehicle'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'partner',
|
||||
@@ -256,25 +272,41 @@ const Vehicles = () => {
|
||||
minSize: 140,
|
||||
meta: { label: 'Partner' },
|
||||
header: () => <SectionLabel>Partner</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
cell: ({ getValue, row }) => (
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="semibold" maxLines={1} style={{ fontSize: 12 }}>
|
||||
{getValue() || '—'}
|
||||
</Text>
|
||||
{row.original.partnerid && (
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
ID #{row.original.partnerid}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'maxweight',
|
||||
accessorKey: 'maxweight',
|
||||
size: 130,
|
||||
minSize: 100,
|
||||
meta: { label: 'Max Weight' },
|
||||
header: () => <SectionLabel>Max Weight</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() ?? '—'
|
||||
},
|
||||
{
|
||||
id: 'battery',
|
||||
accessorKey: 'batterypercentage',
|
||||
size: 110,
|
||||
minSize: 90,
|
||||
meta: { label: 'Battery' },
|
||||
header: () => <SectionLabel>Battery</SectionLabel>,
|
||||
cell: ({ getValue }) => (getValue() != null ? `${getValue()}%` : '—')
|
||||
id: 'specs',
|
||||
accessorFn: (row) => `${row.maxweight || ''} ${row.batterypercentage || ''}`,
|
||||
size: 160,
|
||||
minSize: 130,
|
||||
meta: { label: 'Weight & Battery' },
|
||||
header: () => <SectionLabel>Weight & Battery</SectionLabel>,
|
||||
cell: ({ row }) => (
|
||||
<VStack gap={0.5} padding={0} minWidth={0}>
|
||||
<Text weight="medium" style={{ fontSize: 12 }}>
|
||||
{row.original.maxweight != null ? `${row.original.maxweight} kg max` : '—'}
|
||||
</Text>
|
||||
{row.original.batterypercentage != null && (
|
||||
<HStack gap={0.5} vAlign="center">
|
||||
<MdBatteryChargingFull size={12} color="#10b981" />
|
||||
<Text type="supporting" color="secondary" style={{ fontSize: 11 }}>
|
||||
{row.original.batterypercentage}% battery
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
@@ -283,12 +315,12 @@ const Vehicles = () => {
|
||||
minSize: 100,
|
||||
meta: { label: 'Status' },
|
||||
header: () => <SectionLabel>Status</SectionLabel>,
|
||||
cell: ({ getValue }) => getValue() || '—'
|
||||
cell: ({ getValue }) => <StatusBadge status={getValue()} />
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 120,
|
||||
minSize: 100,
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
meta: { label: 'Actions', align: 'right' },
|
||||
@@ -315,7 +347,6 @@ const Vehicles = () => {
|
||||
)
|
||||
}
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[partnerMap, pagination.pageIndex, pagination.pageSize]
|
||||
);
|
||||
|
||||
@@ -354,10 +385,46 @@ const Vehicles = () => {
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
|
||||
<StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} />
|
||||
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} />
|
||||
<StatCard title="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} />
|
||||
<StatCard
|
||||
title="Total Vehicles"
|
||||
value={stats.total}
|
||||
meta={`${stats.total} vehicles`}
|
||||
icon={<MdDirectionsCar size={20} />}
|
||||
color={BRAND}
|
||||
clickable
|
||||
isSelected={activeFilter === 'all'}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<StatCard
|
||||
title="Available"
|
||||
value={stats.active}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.active / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdDirectionsCar size={20} />}
|
||||
color={STATUS.delivered}
|
||||
clickable
|
||||
isSelected={activeFilter === 'available'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'available' ? 'all' : 'available'))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Low Battery"
|
||||
value={stats.lowBattery}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.lowBattery / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdBatteryStd size={20} />}
|
||||
color={STATUS.pending}
|
||||
clickable
|
||||
isSelected={activeFilter === 'lowBattery'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'lowBattery' ? 'all' : 'lowBattery'))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Full Battery"
|
||||
value={stats.fullBattery}
|
||||
meta={`${stats.total > 0 ? Math.round((stats.fullBattery / stats.total) * 100) : 0}% of ${stats.total}`}
|
||||
icon={<MdBatteryFull size={20} />}
|
||||
color={STATUS.active}
|
||||
clickable
|
||||
isSelected={activeFilter === 'fullBattery'}
|
||||
onClick={() => setActiveFilter((prev) => (prev === 'fullBattery' ? 'all' : 'fullBattery'))}
|
||||
/>
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// brand colour so migrated surfaces match the existing DT token palette
|
||||
// (see themes/dt/tokens.js). Runtime-injected (not `astryx theme build`)
|
||||
// since this repo doesn't do SSR — see `astryx docs theme`.
|
||||
//
|
||||
// The KROW work touched exactly one thing in this file: the radius block.
|
||||
// The colour tokens below are Doormile's own and are unchanged.
|
||||
import { defineTheme } from '@astryxdesign/core/theme';
|
||||
import { neutralTheme } from '@astryxdesign/theme-neutral';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
@@ -42,6 +45,9 @@ export const doormileTheme = defineTheme({
|
||||
// Astryx explicitly does not load fonts, it only names them. Change one
|
||||
// without the other and you get a silent fallback.
|
||||
//
|
||||
// Sora headings + Inter body is also exactly the pairing KROW specifies, so
|
||||
// adopting KROW's type treatment needed no change here.
|
||||
//
|
||||
// base 14, not 16: this is a dense operator console and 14px is what the
|
||||
// existing tables already assume. ratio 1.2 keeps the heading steps tight
|
||||
// so an h1 doesn't tower over a data-heavy page.
|
||||
@@ -53,6 +59,28 @@ export const doormileTheme = defineTheme({
|
||||
code: { family: 'ui-monospace', fallbacks: 'SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' }
|
||||
},
|
||||
tokens: {
|
||||
// ---- Corner radius -------------------------------------------------
|
||||
// Overridden at the token level so EVERY Astryx component moves at once
|
||||
// (cards, dialogs, popovers, buttons, inputs, menus, chat surfaces).
|
||||
// Setting it per-component would leave the ones nobody remembered to
|
||||
// list still rounded.
|
||||
//
|
||||
// These were a flat 5px across all five roles. They are now KROW's scale,
|
||||
// mapped onto those roles: `page` is a page-level surface (20px),
|
||||
// `container` is a card (16px), and `element`/`inner`/`chat` are the
|
||||
// controls and inner chrome that sit inside one (12px). Keeping element
|
||||
// and container DIFFERENT is the whole point — a 16px card holding 16px
|
||||
// inputs has no visual nesting.
|
||||
//
|
||||
// `--radius-full` is deliberately untouched: it drives avatars, status
|
||||
// dots and pill chips, which are meant to be round.
|
||||
'--radius-page': `${DT.radiusPage}px`,
|
||||
'--radius-container': `${DT.radiusCard}px`,
|
||||
'--radius-element': `${DT.radiusField}px`,
|
||||
'--radius-inner': `${DT.radiusInner}px`,
|
||||
'--radius-chat': `${DT.radiusInner}px`,
|
||||
|
||||
// ---- Accent (UNCHANGED) --------------------------------------------
|
||||
'--color-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||
'--color-icon-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||
'--color-text-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
|
||||
@@ -65,6 +93,8 @@ export const doormileTheme = defineTheme({
|
||||
},
|
||||
components: {
|
||||
card: { base: { borderRadius: `${DT.radiusCard}px` } },
|
||||
'text-input': { base: { borderRadius: `${DT.radiusInner}px` } }
|
||||
'text-input': { base: { borderRadius: `${DT.radiusField}px` } },
|
||||
selector: { base: { borderRadius: `${DT.radiusField}px` } },
|
||||
button: { base: { borderRadius: `${DT.radiusField}px` } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,14 +26,30 @@ export const softPopupStyle = {
|
||||
overflow: 'hidden'
|
||||
};
|
||||
|
||||
// Scroll container for a table. Astryx's Table renders a bare <table> with no
|
||||
// sticky-header or max-height affordance (MUI's TableContainer used to provide that),
|
||||
// so pages wrap it in this. The brand-red scrollbar can't be expressed inline
|
||||
// — ::-webkit-scrollbar-* are pseudo-elements — hence the one-time <style>.
|
||||
// ---------------------------------------------------------------------------
|
||||
// TableScroll — scroll container for a table.
|
||||
//
|
||||
// Astryx's Table renders a bare <table> with no sticky-header or max-height
|
||||
// affordance (MUI's TableContainer used to provide that), so pages wrap it in
|
||||
// this. The brand scrollbar can't be expressed inline — ::-webkit-scrollbar-*
|
||||
// are pseudo-elements — hence the one-time <style>.
|
||||
//
|
||||
// It is also where KROW's table-header TYPE treatment lands, and that is what
|
||||
// makes the one-time <style> worth its keep: ~17 pages render tables and none
|
||||
// of them should have to know what a column header looks like. The header is
|
||||
// now the console's heading face at 11px / 600 / +0.06em / uppercase — small,
|
||||
// letterspaced and quiet, so it reads as a LABEL for the data rather than
|
||||
// competing with it.
|
||||
//
|
||||
// Deliberately type only: no colour is set here, so header and body cells keep
|
||||
// whatever Astryx's Table resolves them to, and row hover stays owned by
|
||||
// Astryx's own `hasHover` prop rather than being fought over by two rules.
|
||||
//
|
||||
// `flush` drops the grey header fill and leaves a single hairline under the
|
||||
// header row — for tables that sit directly on the page with no surrounding
|
||||
// card, where a fill plus a border plus a card outline is three lines doing
|
||||
// one job.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush = false, children }) => (
|
||||
<>
|
||||
<style>{`
|
||||
@@ -50,12 +66,30 @@ export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush
|
||||
border: 2px solid transparent; background-clip: padding-box;
|
||||
}
|
||||
.dt-table-scroll::-webkit-scrollbar-thumb:hover { background-color: rgba(15, 23, 42, 0.34); }
|
||||
/* Internal separators, pinned to the same neutral as the card frame so a
|
||||
row line can never read heavier than the table's own outline. Astryx's
|
||||
Table draws its own dividers; this only restates their COLOUR, scoped
|
||||
to our wrapper, so no layout, density or divider MODE is touched. */
|
||||
.dt-table-scroll table td, .dt-table-scroll table th { border-color: ${DT.borderSubtle}; }
|
||||
.dt-table-scroll thead th { position: sticky; top: 0; z-index: 2; }
|
||||
.dt-table-scroll:not(.is-flush) thead th { background-color: ${DT.surfaceAlt}; }
|
||||
.dt-table-scroll.is-flush thead th {
|
||||
background-color: ${DT.surface};
|
||||
border-bottom: 1px solid ${DT.borderSubtle};
|
||||
}
|
||||
/* KROW's column-header treatment — type only, no colour. */
|
||||
.dt-table-scroll thead th {
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Numeric columns opt in per page with class="dt-num"; tabular figures
|
||||
stop a live count from reflowing the column as it ticks. */
|
||||
.dt-table-scroll .dt-num { font-variant-numeric: tabular-nums; }
|
||||
`}</style>
|
||||
<div className={`dt-table-scroll${flush ? ' is-flush' : ''}`} style={{ maxHeight }}>
|
||||
<div style={{ minWidth }}>{children}</div>
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
// dot — Astryx <StatusDot variant>, which only has five values, so
|
||||
// several lifecycle states collapse onto `accent` here.
|
||||
// icon — react-icons component (not an element) so callers size it.
|
||||
// hint — a two-or-three word definition of the state, for surfaces that
|
||||
// show the name and its meaning together (SegmentCard's `range`).
|
||||
// Deliberately terse: it sits beside the label, not under it.
|
||||
// ============================================================================
|
||||
import {
|
||||
MdHourglassEmpty,
|
||||
@@ -41,16 +44,16 @@ import {
|
||||
} from 'react-icons/md';
|
||||
|
||||
export const STATUS_META = {
|
||||
all: { label: 'All', color: '#000000', badge: 'neutral', dot: 'neutral', icon: MdList },
|
||||
pending: { label: 'Pending', color: '#f59e0b', badge: 'yellow', dot: 'warning', icon: MdHourglassEmpty },
|
||||
accepted: { label: 'Accepted', color: '#6366f1', badge: 'blue', dot: 'accent', icon: MdPersonPin },
|
||||
arrived: { label: 'Arrived', color: '#06b6d4', badge: 'cyan', dot: 'accent', icon: MdLocationOn },
|
||||
picked: { label: 'Picked', color: '#8b5cf6', badge: 'purple', dot: 'accent', icon: MdInventory2 },
|
||||
active: { label: 'Active', color: '#14b8a6', badge: 'teal', dot: 'success', icon: MdRoute },
|
||||
skipped: { label: 'Skipped', color: '#f97316', badge: 'orange', dot: 'warning', icon: MdSkipNext },
|
||||
delivered: { label: 'Delivered', color: '#10b981', badge: 'green', dot: 'success', icon: MdCheckCircle },
|
||||
cancelled: { label: 'Cancelled', color: '#ef4444', badge: 'error', dot: 'error', icon: MdCancel },
|
||||
inactive: { label: 'Inactive', color: '#ef4444', badge: 'red', dot: 'error', icon: MdCancel }
|
||||
all: { label: 'All', hint: 'every status', color: '#000000', badge: 'neutral', dot: 'neutral', icon: MdList },
|
||||
pending: { label: 'Pending', hint: 'awaiting rider', color: '#f59e0b', badge: 'yellow', dot: 'warning', icon: MdHourglassEmpty },
|
||||
accepted: { label: 'Accepted', hint: 'rider assigned', color: '#6366f1', badge: 'blue', dot: 'accent', icon: MdPersonPin },
|
||||
arrived: { label: 'Arrived', hint: 'at pickup', color: '#06b6d4', badge: 'cyan', dot: 'accent', icon: MdLocationOn },
|
||||
picked: { label: 'Picked', hint: 'parcel collected', color: '#8b5cf6', badge: 'purple', dot: 'accent', icon: MdInventory2 },
|
||||
active: { label: 'Active', hint: 'in transit', color: '#14b8a6', badge: 'teal', dot: 'success', icon: MdRoute },
|
||||
skipped: { label: 'Skipped', hint: 'attempt failed', color: '#f97316', badge: 'orange', dot: 'warning', icon: MdSkipNext },
|
||||
delivered: { label: 'Delivered', hint: 'completed', color: '#10b981', badge: 'green', dot: 'success', icon: MdCheckCircle },
|
||||
cancelled: { label: 'Cancelled', hint: 'not fulfilled', color: '#ef4444', badge: 'error', dot: 'error', icon: MdCancel },
|
||||
inactive: { label: 'Inactive', hint: 'not in service', color: '#ef4444', badge: 'red', dot: 'error', icon: MdCancel }
|
||||
};
|
||||
|
||||
// Raw backend enums that render as one of the states above. Kept separate from
|
||||
@@ -58,9 +61,18 @@ export const STATUS_META = {
|
||||
// "is this a known alias?" rather than silently falling back.
|
||||
//
|
||||
// The booking enums come from `GET /admin/bookings` (confirmed live — see
|
||||
// orders.js). `converted_to_consignment` is deliberately `accepted` and not
|
||||
// `picked`: it fires when the rider marks pickup COMPLETE, but the operator
|
||||
// workflow on the Orders page treats everything before hand-off as assigned.
|
||||
// orders.js). `converted_to_consignment` stays `accepted` HERE and only here:
|
||||
// this registry renders the badge on the Orders page, whose operator workflow
|
||||
// treats everything before hand-off as assigned.
|
||||
//
|
||||
// The DELIVERIES page classifies the same status as `picked`, in
|
||||
// `mapBookingStatusToDeliveryStatus` (api.js) — deliberately, and the two are
|
||||
// not in conflict. Deliveries tracks the rider's engagement, and
|
||||
// doormile-flow.md §5 is explicit that `pickup-complete` is what converts a
|
||||
// booking into a consignment, so on that page the parcel is in the rider's
|
||||
// hands. Orders tracks the operator's action, where it is still just assigned.
|
||||
// Same booking, two questions, two answers. Don't "fix" one to match the other
|
||||
// without re-reading root CLAUDE.md on why the two taxonomies exist.
|
||||
export const STATUS_ALIASES = {
|
||||
pending_pickup: 'pending',
|
||||
pending_assignment: 'pending',
|
||||
|
||||
@@ -5,16 +5,61 @@
|
||||
// redefining its own DT object, so radius/shadow/color decisions stay in one
|
||||
// place. See CLAUDE.md §6 for the full page-anatomy convention these tokens
|
||||
// support.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCOPE OF THE KROW WORK
|
||||
// ---------------------------------------------------------------------------
|
||||
// The console adopted KROW's *structural* design language — its radius scale,
|
||||
// its type treatment, its page anatomy. It deliberately did NOT adopt KROW's
|
||||
// palette: the colours below are Doormile's own and are unchanged. The brand
|
||||
// is still black, the ink scale is still the slate family, and the lifecycle
|
||||
// hues are untouched.
|
||||
//
|
||||
// So when reading KROW's spec against this file: take the geometry and the
|
||||
// typography, ignore the blue.
|
||||
// ============================================================================
|
||||
export const DT = {
|
||||
// ---- Corner radius -------------------------------------------------
|
||||
// Was a flat 5px on every surface, which read as "tight console" but also
|
||||
// meant a card, the inputs inside it and the chips inside those all had the
|
||||
// same corner — so nothing nested visually.
|
||||
//
|
||||
// Now KROW's scale, which derives from a 12px root: 8 / 12 / 16 / 20. Two
|
||||
// of those steps are both correct and mean different things — 20px is a
|
||||
// page-level surface, 16px is a dense console card. This IS a dense operator
|
||||
// console, so cards take 16 and inputs and inner chrome take 12, which is
|
||||
// what gives a card holding fields a visible hierarchy.
|
||||
//
|
||||
// These are the shared values — change them here, not per page. The Astryx
|
||||
// components read the matching `--radius-*` tokens set in themes/astryx.js,
|
||||
// so both have to move together.
|
||||
//
|
||||
// radiusPill stays fully round on purpose — it is used for things that are
|
||||
// shaped like pills or circles (status chips, avatars, dots). Squaring those
|
||||
// doesn't make them consistent, it makes them look broken.
|
||||
radiusPill: 999,
|
||||
radiusCard: 14,
|
||||
radiusInner: 10,
|
||||
radiusField: 10,
|
||||
// Restrained, low-contrast elevation — corporate (Linear/Stripe), not flashy.
|
||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
||||
radiusPage: 16,
|
||||
radiusCard: 12,
|
||||
radiusInner: 6,
|
||||
radiusField: 6,
|
||||
radiusSm: 4,
|
||||
|
||||
// ---- Elevation -----------------------------------------------------
|
||||
// Restrained, low-contrast — corporate (Linear/Stripe), not flashy.
|
||||
// UNCHANGED by the KROW work; these are the console's own shadows.
|
||||
//
|
||||
// The three original names are the canonical ones. `shadowXs`/`Sm`/`Lg` are
|
||||
// aliases added so newer components can be written against a conventional
|
||||
// xs/sm/lg naming without introducing a second, differently-valued scale.
|
||||
// They resolve to exactly the same three values.
|
||||
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
||||
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
||||
shadowXs: '0 1px 3px rgba(15, 23, 42, 0.06)', // alias of shadowMd
|
||||
shadowSm: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)', // alias of shadowSoft
|
||||
shadowLg: '0 12px 32px rgba(15, 23, 42, 0.12)', // alias of shadowPop
|
||||
|
||||
// ---- Palette (UNCHANGED) -------------------------------------------
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
|
||||
@@ -9,13 +9,25 @@ import { parseDoormileTimestamp } from './doormileTimestamp';
|
||||
// to import this (dispatch/CLAUDE.md is explicit that folder isn't touched
|
||||
// casually); this is purely additive.
|
||||
//
|
||||
// Half-open [startHour, endHour) ranges in LOCAL time. Gaps (8–9am,
|
||||
// 12:30pm–4pm, after 7pm) are intentional — a row in a gap belongs to no
|
||||
// batch, not the nearest one.
|
||||
// Half-open [startHour, endHour) ranges in LOCAL time, and they COVER THE
|
||||
// WHOLE DAY. That is the point.
|
||||
//
|
||||
// They used to be 0–8, 9–12.5 and 16–19 — 14.5 of 24 hours, with gaps at
|
||||
// 8–9am, 12:30–4pm and after 7pm. Those windows came from jupiter, where
|
||||
// bucketing ran on `expecteddeliverytime` and they described PROMISED DELIVERY
|
||||
// SLOTS. This app buckets on `orderdate` (when the order was placed) instead,
|
||||
// and orders are placed all day: an order created at 2:43pm fell in the
|
||||
// 12:30–4pm gap, belonged to no batch, and vanished from every batch filter on
|
||||
// both the Deliveries and Dispatch pages. 40% of the clock was a black hole.
|
||||
//
|
||||
// Each window now runs to the start of the next, so every row lands in exactly
|
||||
// one batch. Every START hour is unchanged, which makes this strictly additive:
|
||||
// no row that already had a batch moves to a different one — only rows that had
|
||||
// none gain one.
|
||||
export const BATCHES = [
|
||||
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 8 },
|
||||
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||||
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 19 }
|
||||
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 9 },
|
||||
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 16 },
|
||||
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 24 }
|
||||
];
|
||||
|
||||
export const getBatchForHour = (h) => {
|
||||
@@ -25,6 +37,18 @@ export const getBatchForHour = (h) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Human-readable window, e.g. "12 AM–9 AM" / "After 4 PM". Kept here so the
|
||||
// pages' pickers describe the same ranges they filter by.
|
||||
const clock = (h) => {
|
||||
const whole = Math.floor(h);
|
||||
const mins = Math.round((h - whole) * 60);
|
||||
const suffix = whole >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = whole % 12 === 0 ? 12 : whole % 12;
|
||||
return mins ? `${hour12}:${String(mins).padStart(2, '0')} ${suffix}` : `${hour12} ${suffix}`;
|
||||
};
|
||||
|
||||
export const batchRangeLabel = (b) => (b.endHour >= 24 ? `After ${clock(b.startHour)}` : `${clock(b.startHour)}–${clock(b.endHour)}`);
|
||||
|
||||
// The one field batch bucketing uses across the app: a booking's `orderdate`
|
||||
// (== createdat — see fetchDeliveries in api.js). `assigntime` and
|
||||
// `expecteddeliverytime` were both tried and rejected — see dispatch/
|
||||
|
||||
150
src/utils/bulkOrderColumns.js
Normal file
150
src/utils/bulkOrderColumns.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// ==============================|| Bulk-order sheet columns ||============================== //
|
||||
//
|
||||
// The single definition of what a bulk-order spreadsheet may look like. Two
|
||||
// consumers share it:
|
||||
//
|
||||
// • pages/nearle/orders/multipleOrders.js — the console's bulk-upload page,
|
||||
// where this map originated.
|
||||
// • pages/nearle/assistant/bulkFile.js — Doormile AI's file upload.
|
||||
//
|
||||
// It lives here so a sheet that uploads on the page also uploads in the bot,
|
||||
// permanently. Copying it into the assistant was the alternative and was
|
||||
// rejected for the obvious reason: five pages once carried private STATUS_META
|
||||
// copies that drifted into disagreeing about the same order's label.
|
||||
//
|
||||
// SHEET_HEADER_MAP is reproduced from the page byte for byte, including the `*`
|
||||
// suffix convention (a starred header is required) and the note about which
|
||||
// mappings were never confirmed against a live tenant sheet. Do not "tidy" the
|
||||
// keys — they are the tenant's actual column titles, spaces and all.
|
||||
|
||||
export const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
|
||||
|
||||
// A receiver name arrives prefixed with the sheet's own row numbering
|
||||
// ("12. Ravi Kumar") often enough that stripping it is part of parsing.
|
||||
export const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
|
||||
|
||||
export const SHEET_HEADER_MAP = {
|
||||
'pickupdate(yyyy-mmm-dd)': 'date',
|
||||
'sendername*': 'locationname',
|
||||
'senderphone*': 'locationcontact',
|
||||
'senderaddress*': 'locationaddress',
|
||||
'receivername*': 'firstname',
|
||||
receiverphone: 'contactno',
|
||||
'receiveralternatephone*': 'altcontactno',
|
||||
receiverfulladdress: 'address',
|
||||
// Best-effort — no live sample sheet to confirm these are the tenant's
|
||||
// actual column headers. Without them deliverycity/deliverypincode in
|
||||
// the submit payload were always sent blank; if these names are wrong,
|
||||
// behavior is unchanged from before (still blank), not worse.
|
||||
receivercity: 'city',
|
||||
receiverpincode: 'postcode',
|
||||
receiverlatitude: 'latitude',
|
||||
receiverlongitude: 'longitude',
|
||||
'itemdescription*': 'description',
|
||||
Quantity: 'quantity',
|
||||
' Collect Cash': 'collectionamt'
|
||||
};
|
||||
|
||||
// Starred headers are the ones the page reports as missing.
|
||||
export const requiredSheetColumns = () => Object.keys(SHEET_HEADER_MAP).filter((k) => k.trim().endsWith('*'));
|
||||
|
||||
// ---- assistant-side translation ---------------------------------------------
|
||||
//
|
||||
// The page consumes the internal names above (`firstname`, `contactno`, …)
|
||||
// because its row editors were built on them. The assistant's rows are shaped
|
||||
// like a booking payload instead, so the two are bridged here rather than in
|
||||
// either consumer.
|
||||
const INTERNAL_TO_ROW = {
|
||||
firstname: 'customer_name',
|
||||
contactno: 'customer_phone',
|
||||
address: 'deliveryaddress',
|
||||
postcode: 'deliverypincode',
|
||||
city: 'deliverycity',
|
||||
latitude: 'deliverylatitude',
|
||||
longitude: 'deliverylongitude',
|
||||
description: 'itemdescription',
|
||||
quantity: 'quantity'
|
||||
};
|
||||
|
||||
// Headers someone typing their own sheet actually writes. Accepted in addition
|
||||
// to the tenant's official titles, never instead of them.
|
||||
//
|
||||
// `collectionamt` (the sheet's "Collect Cash") is deliberately NOT treated as a
|
||||
// price: it is cash to collect from the recipient, while `finalprice` is what
|
||||
// the delivery costs. Mapping one onto the other would quietly bill the wrong
|
||||
// number on every row.
|
||||
const PLAIN_ALIASES = {
|
||||
customer_name: ['name', 'customername', 'customer', 'recipient', 'recipientname', 'receiver', 'to'],
|
||||
customer_phone: ['phone', 'mobile', 'phonenumber', 'mobilenumber', 'customerphone', 'contact', 'recipientphone'],
|
||||
deliveryaddress: ['address', 'deliveryaddress', 'dropaddress', 'fulladdress', 'destination'],
|
||||
deliverypincode: ['pincode', 'pin', 'postcode', 'zip', 'zipcode', 'deliverypincode'],
|
||||
deliverycity: ['city', 'town', 'deliverycity'],
|
||||
deliverylatitude: ['latitude', 'lat', 'deliverylatitude'],
|
||||
deliverylongitude: ['longitude', 'lng', 'lon', 'deliverylongitude'],
|
||||
itemdescription: ['description', 'item', 'itemdescription', 'contents', 'goods', 'particulars'],
|
||||
itemcategory: ['category', 'itemcategory', 'type'],
|
||||
quantity: ['quantity', 'qty', 'parcels', 'pieces', 'count'],
|
||||
finalprice: ['price', 'amount', 'charge', 'deliverycharge', 'finalprice', 'rate', 'fare'],
|
||||
weight: ['weight', 'kg', 'kgs']
|
||||
};
|
||||
|
||||
// normalised header → assistant row field. Built once; the tenant's official
|
||||
// titles win over a plain alias if a sheet somehow carries both.
|
||||
const ROW_FIELD_BY_HEADER = (() => {
|
||||
const out = {};
|
||||
Object.entries(PLAIN_ALIASES).forEach(([field, headers]) => {
|
||||
headers.forEach((h) => {
|
||||
out[normalizeHeader(h)] = field;
|
||||
});
|
||||
});
|
||||
Object.entries(SHEET_HEADER_MAP).forEach(([sheetHeader, internal]) => {
|
||||
const field = INTERNAL_TO_ROW[internal];
|
||||
if (!field) return;
|
||||
out[normalizeHeader(sheetHeader)] = field;
|
||||
out[normalizeHeader(sheetHeader).replace(/\*+$/, '')] = field;
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
// The `*` in a sheet title is a "this one is required" annotation the tenant
|
||||
// types, not part of the column's name — so `Receiver Phone*` and
|
||||
// `ReceiverPhone` are the same column and both must resolve. Only the
|
||||
// assistant's lookup is star-tolerant; normalizeHeader itself is untouched,
|
||||
// because multipleOrders.js derives its missing-required warning from the star
|
||||
// and loosening that would change the page's behaviour.
|
||||
const unstar = (h) => normalizeHeader(h).replace(/\*+$/, '');
|
||||
|
||||
export const rowFieldForHeader = (header) => ROW_FIELD_BY_HEADER[normalizeHeader(header)] || ROW_FIELD_BY_HEADER[unstar(header)] || null;
|
||||
|
||||
// Every field the assistant can fill from a sheet, for the downloadable template.
|
||||
export const TEMPLATE_HEADERS = [
|
||||
'Receiver Name*',
|
||||
'Receiver Phone*',
|
||||
'Receiver Full Address*',
|
||||
'Receiver Pincode*',
|
||||
'Receiver City',
|
||||
'Item Description*',
|
||||
'Quantity',
|
||||
'Price'
|
||||
];
|
||||
|
||||
// ---- one sheet row → one assistant row --------------------------------------
|
||||
//
|
||||
// Unrecognised columns are RETURNED, not dropped silently: an operator whose
|
||||
// price column is titled something unexpected needs to be told it was ignored,
|
||||
// or they'll submit 200 orders priced from a column that was never read.
|
||||
export const mapSheetRow = (raw) => {
|
||||
const row = {};
|
||||
const ignored = [];
|
||||
|
||||
Object.entries(raw || {}).forEach(([header, value]) => {
|
||||
const field = rowFieldForHeader(header);
|
||||
if (!field) {
|
||||
if (String(header || '').trim() && String(value ?? '').trim()) ignored.push(String(header).trim());
|
||||
return;
|
||||
}
|
||||
row[field] = field === 'customer_name' ? cleanReceiverName(value) : value;
|
||||
});
|
||||
|
||||
return { row, ignored };
|
||||
};
|
||||
@@ -6,6 +6,13 @@
|
||||
* @param {Object} destination - { latitude, longitude }
|
||||
* @returns {Promise<number>} - Round distance in KM
|
||||
*/
|
||||
// Duration from the most recent OSRM route, in minutes, or null when the
|
||||
// Haversine fallback was used (a straight-line estimate has no honest
|
||||
// duration — better to show nothing than a made-up ETA).
|
||||
let lastRouteDurationMin = null;
|
||||
|
||||
export const getLastRouteDurationMin = () => lastRouteDurationMin;
|
||||
|
||||
export const calculateDrivingDistance = async (origin, destination) => {
|
||||
const lat1 = origin?.latitude;
|
||||
const lon1 = origin?.longitude;
|
||||
@@ -26,6 +33,10 @@ export const calculateDrivingDistance = async (origin, destination) => {
|
||||
if (data.routes && data.routes.length > 0) {
|
||||
const distanceInMeters = data.routes[0].distance;
|
||||
const distanceInKm = distanceInMeters / 1000;
|
||||
// OSRM already returns the driving duration alongside the distance;
|
||||
// it was being discarded. Cached here so callers that want an ETA get
|
||||
// a REAL routed figure instead of dividing km by a guessed speed.
|
||||
lastRouteDurationMin = Number.isFinite(data.routes[0].duration) ? Math.round(data.routes[0].duration / 60) : null;
|
||||
return Math.round(distanceInKm);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +55,9 @@ export const calculateDrivingDistance = async (origin, destination) => {
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const aerialDistance = R * c;
|
||||
|
||||
|
||||
// No route was fetched, so there is no honest duration to report.
|
||||
lastRouteDurationMin = null;
|
||||
return Math.round(aerialDistance * 1.3);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,21 @@ doormileAxios.interceptors.response.use(
|
||||
localStorage.clear();
|
||||
window.location.replace('/login');
|
||||
}
|
||||
return Promise.reject(error.response?.data || error);
|
||||
// Reject with the server's own body (callers read `err.message`), but keep
|
||||
// the HTTP status reachable as `err.httpStatus`.
|
||||
//
|
||||
// Without this the status is lost entirely: every caller's `err.response`
|
||||
// is undefined, so a 404 and a 500 and a validation failure all collapse
|
||||
// into the same generic "something went wrong". The assistant's write
|
||||
// paths need to tell "that route doesn't exist" apart from "your body was
|
||||
// wrong", and that distinction only exists in the status code.
|
||||
//
|
||||
// Non-enumerable so nothing that spreads or JSON-stringifies the body
|
||||
// starts carrying it around.
|
||||
const body = error.response?.data;
|
||||
if (body === undefined || body === null) return Promise.reject(error);
|
||||
const payload = typeof body === 'object' ? body : { message: String(body) };
|
||||
return Promise.reject(Object.defineProperty(payload, 'httpStatus', { value: error.response?.status, enumerable: false }));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user