Compare commits
53 Commits
b760c1a078
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f91044e61a | |||
| 8960fb8ebb | |||
| 262b83ffbc | |||
| d24df891fb | |||
| 56e8a95c6a | |||
| 990dd0dc23 | |||
| a764df225f | |||
| b7f9a6aaeb | |||
| bcb5d1de28 | |||
| 7f2110d51e | |||
| 1eea29cf23 | |||
| 0a828fbe4f | |||
| 12617b5d81 | |||
| b73f050669 | |||
| e3fc144d05 | |||
| f1299fd053 | |||
| 7be012730b | |||
| 9a991fe5ab | |||
| eeef8851e4 | |||
| c9616edad9 | |||
| 153f87b577 | |||
| 21818320a0 | |||
| 9314771aa8 | |||
| d169d24934 | |||
| 72f3bb0961 | |||
| 46b73c50f4 | |||
| 174e7dd890 | |||
| 3d404665ab | |||
| 2f0836df30 | |||
| aca6344cc9 | |||
| a13f7caddc | |||
| 23da1872f2 | |||
| beeee893f6 | |||
| f3fe53d2ac | |||
| 8537b09f33 | |||
| 34c68034de | |||
| cca3c50a89 | |||
| 19ed3585ff | |||
| 93735e232a | |||
| 041dc37861 | |||
| 115a06a02c | |||
| 2cd048e6f0 | |||
| f25b3fcf65 | |||
| 5cb9872037 | |||
| 98398894a5 | |||
| b2104d21b0 | |||
| 0a11f7543c | |||
| 81b7d32672 | |||
| 8b2c4daef8 | |||
| 79a8f2b234 | |||
| e069068ce7 | |||
| 35250717fc | |||
| 34bf7989f7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,3 +10,6 @@ dist
|
||||
# stayed for weeks.
|
||||
*.zip
|
||||
*.tgz
|
||||
|
||||
# Build output from scripts/mapPreview.mjs — a local viewer, never committed.
|
||||
scripts/.preview/
|
||||
|
||||
54
Dockerfile
54
Dockerfile
@@ -13,9 +13,30 @@ COPY package*.json ./
|
||||
# never pinned, so the deployed bundle is built from dependencies nobody chose
|
||||
# and nobody can reproduce.
|
||||
#
|
||||
# When this line fails, the fix is `npm install` locally and COMMIT the updated
|
||||
# package-lock.json. The build should not paper over a lock file that is out of
|
||||
# date; it should say so.
|
||||
# When this line fails, the fix is to update package-lock.json locally and
|
||||
# COMMIT it. The build should not paper over a lock file that is out of date;
|
||||
# it should say so.
|
||||
#
|
||||
# ── But `npm install` alone is how the lock got broken once ─────────────────
|
||||
#
|
||||
# This image is node:22-alpine, which ships npm 10.9.8. A developer on npm 11
|
||||
# running `npm install` rewrites the lock in a shape npm 10 rejects: npm 11
|
||||
# prunes optional platform packages that npm 10 still validates. Adding leaflet
|
||||
# on npm 11.6.2 dropped `@emnapi/core` and `@emnapi/runtime` — transitive
|
||||
# optional deps of `@tailwindcss/oxide-wasm32-wasi` — and the next deploy died
|
||||
# here with "Missing: @emnapi/core@1.11.3 from lock file". Nothing was wrong
|
||||
# with the code; the lock was genuinely incomplete and this line was right to
|
||||
# refuse it.
|
||||
#
|
||||
# So after changing dependencies, prove the lock against THIS npm before
|
||||
# pushing, in a scratch directory so node_modules is not disturbed:
|
||||
#
|
||||
# mkdir /tmp/lockcheck && cp package.json package-lock.json /tmp/lockcheck/
|
||||
# cd /tmp/lockcheck && npx npm@10.9.8 ci
|
||||
#
|
||||
# and if it fails, regenerate with the same version:
|
||||
#
|
||||
# npx npm@10.9.8 install --package-lock-only
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
@@ -33,6 +54,33 @@ COPY . .
|
||||
ARG VITE_API_BASE="https://fiesta.nearle.app"
|
||||
ENV VITE_API_BASE=$VITE_API_BASE
|
||||
|
||||
# Which console this image is.
|
||||
#
|
||||
# platform → platform.nearledaily.com, Nearle's own staff
|
||||
# merchant → app.nearledaily.com, merchants and their branch users
|
||||
#
|
||||
# The same source builds both. What the flag changes is which workspace's routes
|
||||
# are mounted and which roles may sign in — neither image lets the other's
|
||||
# accounts through. Both images still CONTAIN both workspaces' compiled chunks;
|
||||
# the unmounted one is never fetched because nothing routes to it.
|
||||
#
|
||||
# Defaulting to `merchant` keeps every existing deployment behaving exactly as
|
||||
# it did. The platform build is the one that has to be asked for — the opposite
|
||||
# default would turn every environment that had not been told about this into a
|
||||
# platform console on the day it shipped.
|
||||
# There is no workspace flag in this image.
|
||||
#
|
||||
# One existed while a single codebase served both consoles and had to be told
|
||||
# which it was. Nearle's own staff now have their own application —
|
||||
# `nearle-platform`, its own repository and its own deploy — and this image is
|
||||
# the merchant console and nothing else.
|
||||
#
|
||||
# The platform console's address, for the sentence shown to a staff member who
|
||||
# signs in at the wrong site. A build argument rather than a constant because
|
||||
# the two are separate deployments and either can move.
|
||||
ARG VITE_PLATFORM_HOST="platform.nearledaily.com"
|
||||
ENV VITE_PLATFORM_HOST=$VITE_PLATFORM_HOST
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2 — serve
|
||||
|
||||
31
README.md
31
README.md
@@ -35,17 +35,36 @@ deployed build set `VITE_API_BASE` (see `.env.example`).
|
||||
|
||||
## What's built
|
||||
|
||||
Only the **Nearle Admin** workspace so far:
|
||||
All three workspaces. Which one opens is decided by the account, not chosen —
|
||||
see `src/auth/roles.ts`.
|
||||
|
||||
**Nearle Admin** (`issuperadmin`) — the platform operator:
|
||||
|
||||
- `/nearle/stores` — every tenant, branch counts, per-tenant performance
|
||||
- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue
|
||||
- `/nearle/onboard/tenant` — provision a merchant group
|
||||
- `/nearle/onboard/branch` — commission an outlet with its delivery thresholds
|
||||
- `/nearle/onboard/tenant` — provision a merchant group and its first outlet
|
||||
- `/nearle/catalogue` — the global catalogue, plus both product-import paths
|
||||
- `/nearle/partners` — rider partners, and the riders under each
|
||||
- `/nearle/dispatch` — every partner's live work and shifts
|
||||
- `/nearle/uploads` — spreadsheets sent to the catalogue service
|
||||
|
||||
`/admin/*` (Store Admin) and `/store/*` (Store Manager) render a named
|
||||
placeholder rather than a 404, so those roles land somewhere that explains
|
||||
itself.
|
||||
**Store Admin** (roleid 1 and 3) — one merchant, every branch:
|
||||
|
||||
- `/admin/console` — online and counter sales side by side, per branch
|
||||
- `/admin/sales` · `/admin/dispatch` · `/admin/inventory` · `/admin/reports`
|
||||
- `/admin/branches/new` — commission an outlet with its delivery thresholds
|
||||
- `/admin/users` — back-office people and till accounts
|
||||
- `/admin/terminals` · `/admin/uploads` · `/admin/profile` · `/admin/onboarding`
|
||||
|
||||
**Store user** (everything else) — one branch, scoped to it:
|
||||
|
||||
- `/store/console` · `/store/products` · `/store/sales` · `/store/dispatch`
|
||||
- `/store/reports` · `/store/customers` · `/store/terminals` · `/store/staff`
|
||||
- `/store/uploads` · `/store/account` · `/store/setup`
|
||||
|
||||
Two routes are redirects rather than pages, and deliberately:
|
||||
`/nearle/onboard/branch` → `/nearle/stores` (a branch is commissioned from the
|
||||
tenant that will own it), and `/nearle/fleet` → `/nearle/dispatch`.
|
||||
|
||||
## Things about the backend that shape this code
|
||||
|
||||
|
||||
603
package-lock.json
generated
603
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@astryxdesign/core": "^0.4.5",
|
||||
"@stylexjs/stylex": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
@@ -21,16 +22,72 @@
|
||||
"devDependencies": {
|
||||
"@astryxdesign/cli": "^0.4.5",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
|
||||
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.1.10",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
|
||||
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@astryxdesign/cli": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.4.5.tgz",
|
||||
@@ -691,6 +748,159 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
|
||||
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
|
||||
"integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz",
|
||||
"integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -1133,6 +1343,24 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz",
|
||||
@@ -1997,6 +2225,43 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsdom": {
|
||||
"version": "30.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz",
|
||||
"integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/tough-cookie": "*",
|
||||
"parse5": "^8.0.0",
|
||||
"undici-types": "^8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsdom/node_modules/undici-types": {
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.2.tgz",
|
||||
"integrity": "sha512-7/+aSjzkUoLc92hV22bTW4aGanXf800zbwguhcICs0OAoCF9wDOE4wkopQ+SqfhXZm8mCK8gHpdTs7pZUWzK3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
@@ -2027,6 +2292,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -2429,6 +2701,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
@@ -2592,6 +2874,20 @@
|
||||
"integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==",
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2720,6 +3016,35 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2738,6 +3063,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
@@ -2775,6 +3107,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
|
||||
@@ -2977,6 +3322,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.18",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||
@@ -3038,6 +3396,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isobject": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
@@ -3105,6 +3470,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
|
||||
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.3.0",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.9.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3141,6 +3557,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -3481,6 +3903,13 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3563,6 +3992,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
|
||||
@@ -3655,6 +4097,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -3816,6 +4268,16 @@
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
@@ -3863,6 +4325,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -3960,6 +4435,13 @@
|
||||
"integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
@@ -4004,6 +4486,26 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.12",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
|
||||
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.12"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.12",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
|
||||
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
@@ -4014,6 +4516,32 @@
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -4075,6 +4603,16 @@
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
@@ -4483,6 +5021,54 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
|
||||
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
@@ -4536,6 +5122,23 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 3100",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test \"src/**/*.test.ts\"",
|
||||
"test": "tsx --import ./tools/stub-css.mjs --test \"src/**/*.test.ts\" \"src/**/*.test.tsx\"",
|
||||
"contract": "node scripts/contract.mjs",
|
||||
"db": "node scripts/db.mjs",
|
||||
"appgap": "node scripts/appgap.mjs",
|
||||
@@ -16,12 +16,14 @@
|
||||
"check:optimiser": "tsx scripts/checkOptimiser.ts",
|
||||
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"",
|
||||
"appsweep": "node scripts/appsweep.mjs",
|
||||
"appfix": "node scripts/appfix.mjs"
|
||||
"appfix": "node scripts/appfix.mjs",
|
||||
"preview:map": "node scripts/mapPreview.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astryxdesign/core": "^0.4.5",
|
||||
"@stylexjs/stylex": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
@@ -32,10 +34,13 @@
|
||||
"devDependencies": {
|
||||
"@astryxdesign/cli": "^0.4.5",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^7.0.2",
|
||||
|
||||
167
scripts/mapPreview.mjs
Normal file
167
scripts/mapPreview.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Build a standalone page that renders the REAL dispatch map with mock stops.
|
||||
*
|
||||
* ── Why this exists ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* A leaflet map cannot be checked by anything else in this repo. The pure tests
|
||||
* never mount it, `renderToString` never runs the effect that builds it, and
|
||||
* the jsdom tests can only COUNT what it produced — none of them can tell you
|
||||
* whether the thing looks right. The map shipped twice on that basis and came
|
||||
* back wrong twice: once invisible behind a crash, once with its routes buried
|
||||
* under 966 pins.
|
||||
*
|
||||
* So this bundles the actual `GroupMap` — not a copy of it, not a sketch —
|
||||
* against invented stops, and writes one HTML file to open. What you see is
|
||||
* what the console draws.
|
||||
*
|
||||
* ── Why the mock data lives here and not in `src` ───────────────────────────
|
||||
*
|
||||
* `npm run verify:live` asserts there is no `src/demo`, and it is right to:
|
||||
* a fixture layer inside the app is how a screen ends up quietly rendering
|
||||
* invented numbers in production. This is a build tool. It imports from `src`
|
||||
* and nothing in `src` imports it, so the app has no path to this data.
|
||||
*
|
||||
* node scripts/mapPreview.mjs → writes scripts/.preview/map.html
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const out = join(here, '.preview');
|
||||
|
||||
/* ── The mock day ─────────────────────────────────────────────────────────
|
||||
Shaped like the real thing rather than like a neat demo: one shop, two
|
||||
riders working outward in a loop, three orders stacked on one address, and
|
||||
one rider whose last reported position is nowhere near their last drop. Each
|
||||
of those is something the live data does and each has broken this map once. */
|
||||
const SHOP = { lat: 11.0168, lng: 76.9558 };
|
||||
|
||||
const ROUND_A = [
|
||||
[11.0245, 76.9601],
|
||||
[11.0298, 76.9662],
|
||||
[11.0331, 76.9754],
|
||||
[11.0288, 76.9823],
|
||||
[11.0201, 76.9788],
|
||||
// Three orders at one address — the repeat customer that stacked 379 pins.
|
||||
[11.0154, 76.9702],
|
||||
[11.0154, 76.9702],
|
||||
[11.0154, 76.9702],
|
||||
];
|
||||
|
||||
const ROUND_B = [
|
||||
[11.0102, 76.9481],
|
||||
[11.0044, 76.9412],
|
||||
[10.9981, 76.9377],
|
||||
[10.9932, 76.9455],
|
||||
[11.0011, 76.9521],
|
||||
];
|
||||
|
||||
function stopsFor(rider, userid, points, from) {
|
||||
return points.map((point, index) => ({
|
||||
kind: 'delivery',
|
||||
row: {
|
||||
deliveryid: userid * 100 + index,
|
||||
orderid: `916-${userid}${String(index + 1).padStart(2, '0')}`,
|
||||
userid,
|
||||
ridername: rider,
|
||||
// A couple left open, so the status colours are visible on the pins.
|
||||
orderstatus: index === points.length - 1 ? 'active' : 'delivered',
|
||||
assigntime: '2026-08-25 09:00:00',
|
||||
deliverytime: `2026-08-25 ${String(from + Math.floor(index / 2)).padStart(2, '0')}:${String((index * 17) % 60).padStart(2, '0')}:00`,
|
||||
pickuplat: String(SHOP.lat),
|
||||
pickuplon: String(SHOP.lng),
|
||||
droplat: String(point[0]),
|
||||
droplon: String(point[1]),
|
||||
// Only the last stop carries a rider fix, which is the shape the live
|
||||
// rows have — a position arrives when a job moves, not per stop.
|
||||
...(index === points.length - 1
|
||||
? { riderslat: String(point[0] + 0.004), riderslon: String(point[1] - 0.003) }
|
||||
: {}),
|
||||
deliveryamt: 30 + index * 5,
|
||||
deliverycustomer: `${rider}'s customer ${index + 1}`,
|
||||
deliveryaddress: `Stop ${index + 1}`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const STOPS = [
|
||||
...stopsFor('Varun', 897, ROUND_A, 10),
|
||||
...stopsFor('Murali', 1111, ROUND_B, 11),
|
||||
];
|
||||
|
||||
const entry = join(out, 'entry.jsx');
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import { createRoot } from 'react-dom/client';
|
||||
import { GroupMap } from '../../src/features/store-admin/GroupMap';
|
||||
const STOPS = ${JSON.stringify(STOPS)};
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<div style={{ padding: 16, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<h1 style={{ font: '600 18px system-ui', margin: '0 0 4px' }}>Dispatch map — mock day</h1>
|
||||
<p style={{ font: '13px system-ui', color: '#5b6472', margin: '0 0 16px' }}>
|
||||
Two riders, one shop, ${STOPS.length} stops. Three of Varun's orders are at one address.
|
||||
This is the real GroupMap component with invented stops.
|
||||
</p>
|
||||
<GroupMap stops={STOPS} groupName="the mock day" />
|
||||
</div>,
|
||||
);
|
||||
`,
|
||||
);
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
outfile: join(out, 'map.js'),
|
||||
jsx: 'automatic',
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
// Leaflet's stylesheet references sprite PNGs for controls we do not use;
|
||||
// inlined as data URIs so the page is a single self-contained file.
|
||||
loader: { '.css': 'css', '.png': 'dataurl', '.svg': 'dataurl' },
|
||||
// The real thing, minus the parts a static page has no business having.
|
||||
// `import.meta.env` is Vite's and does not exist here; the modules that read
|
||||
// it already optional-chain, so an empty object is enough.
|
||||
define: { 'import.meta.env': 'undefined', 'process.env.NODE_ENV': '"production"' },
|
||||
logLevel: 'warning',
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(out, 'map.html'),
|
||||
`<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dispatch map preview</title>
|
||||
<link rel="stylesheet" href="./map.css">
|
||||
<style>
|
||||
/* The console's own tokens, so the map is styled as it is in the app. */
|
||||
:root {
|
||||
--color-brand: #662582; --color-brand-strong: #531b6e; --color-brand-tint: #f4eef8;
|
||||
--color-ink-1: #1e2530; --color-ink-2: #414a58; --color-ink-3: #5b6472; --color-ink-4: #97a1b0;
|
||||
--color-surface: #fff; --color-surface-subtle: #fafbfc; --color-surface-sunken: #f2f4f7;
|
||||
--color-border: #e3e7ec;
|
||||
}
|
||||
body { margin: 0; background: #fff; font-family: system-ui, sans-serif; }
|
||||
.pva-note { display:flex; gap:7px; align-items:flex-start; padding:8px 10px; border-radius:7px;
|
||||
background: var(--color-surface-subtle); font-size:11.5px; line-height:1.5; color: var(--color-ink-3); }
|
||||
.map-key { display:inline-flex; gap:5px; align-items:center; font-size:11px; color:var(--color-ink-4); }
|
||||
.map-key i { width:9px; height:9px; margin-left:6px; border-radius:50%; background:var(--color-ink-4); }
|
||||
.map-key i:first-child { margin-left:0 }
|
||||
.map-key i[data-key='shop'] { border-radius:2px; background:var(--color-brand) }
|
||||
.map-key i[data-key='drop'] { background:#10b981 }
|
||||
.map-key i[data-key='rider'] { background:transparent; border:2px solid var(--color-ink-3) }
|
||||
.rider-chip { display:inline-flex; gap:6px; align-items:center; padding:4px 10px; border:1px solid var(--color-border);
|
||||
border-radius:999px; background:#fff; font:inherit; font-size:12px; color:var(--color-ink-2); cursor:pointer }
|
||||
.rider-chip[data-active='true'] { border-color:var(--color-brand); background:var(--color-brand-tint); color:var(--color-ink-1) }
|
||||
.rider-chip i { width:8px; height:8px; border-radius:50% }
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
<script src="./map.js"></script>
|
||||
`,
|
||||
);
|
||||
|
||||
console.log('preview written to', join(out, 'map.html'));
|
||||
console.log('open it in a browser to see the real map with mock stops');
|
||||
59
src/App.tsx
59
src/App.tsx
@@ -5,7 +5,7 @@ import { RequireRole, useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
|
||||
import { SetPasswordPage } from '@/features/auth/SetPasswordPage';
|
||||
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
||||
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
||||
|
||||
@@ -28,12 +28,8 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
||||
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
||||
lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] }))));
|
||||
|
||||
const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage'));
|
||||
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
||||
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
|
||||
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
|
||||
const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage'));
|
||||
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
|
||||
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
|
||||
its stylesheet, which nobody who never opens the fleet map should download. */
|
||||
|
||||
/* One Console for both workspaces — it reads its own scope from BranchScope,
|
||||
which pins a store user to their outlet and lets an admin choose. Both routes
|
||||
@@ -84,38 +80,25 @@ export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* Reached only from an invitation email, and public by necessity — the
|
||||
account has no password yet, so there is no way to obtain a session
|
||||
first. The token in the query string is the whole credential. */}
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
{/* Nearle Admin — the platform workspace */}
|
||||
<Route
|
||||
path="/nearle"
|
||||
element={
|
||||
<RequireRole role="nearle-admin">
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<NearleAdminShell />
|
||||
</Suspense>
|
||||
</RequireRole>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/nearle/stores" replace />} />
|
||||
<Route path="stores" element={<StoresPage />} />
|
||||
<Route path="stores/:tenantId" element={<StoreDetailPage />} />
|
||||
<Route path="onboard/tenant" element={<OnboardTenantPage />} />
|
||||
{/* Branch onboarding moved to the Store Admin workspace — the merchant
|
||||
opens their own outlets. Kept as a redirect rather than deleted so a
|
||||
bookmark or a stale link lands on the directory instead of a 404. */}
|
||||
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
|
||||
<Route path="catalogue" element={<GlobalCataloguePage />} />
|
||||
{/* Delivery partners — the companies that supply riders. Platform-side
|
||||
only: a merchant is assigned one, never allowed to create one. */}
|
||||
<Route path="partners" element={<PartnersPage />} />
|
||||
<Route path="uploads" element={<NearleUploadsPage />} />
|
||||
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
|
||||
never bounce out to a HOME_ROUTE that points back into this
|
||||
workspace and loop. */}
|
||||
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
|
||||
</Route>
|
||||
{/* Nearle staff have their own application.
|
||||
|
||||
The platform workspace lived here until the two consoles were split.
|
||||
It is now `nearle-platform`, a separate repository deployed on its own
|
||||
domain, and this one serves merchants and their branch users only.
|
||||
|
||||
`/nearle/*` is therefore not a protected path here — it is not a path
|
||||
at all, and falls to the catch-all like any other unknown URL. A
|
||||
`nearle-admin` account cannot sign in either: `login` and `restore`
|
||||
refuse it before a session is written. */}
|
||||
|
||||
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
||||
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
@@ -179,6 +162,10 @@ export function App() {
|
||||
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* `user` is only ever a merchant role — `login` and `restore` refuse a
|
||||
Nearle staff account before a session is written — so its home route
|
||||
is always one of the two workspaces above, and this cannot bounce
|
||||
somewhere that does not exist. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
|
||||
|
||||
117
src/api/assistant.ts
Normal file
117
src/api/assistant.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Nearle Buddy.
|
||||
*
|
||||
* Two calls: is this switched on, and here is a question. The tenant is not one
|
||||
* of them — the server reads it from the session token, which is the whole
|
||||
* point. A request body that carried a tenant would be a request body somebody
|
||||
* could edit.
|
||||
*/
|
||||
|
||||
import { api, errorMessage, WEB } from './client';
|
||||
|
||||
/** One tool the assistant ran, so an answer can show its working. */
|
||||
export interface AssistantStep {
|
||||
tool: string;
|
||||
/** `ok` or `refused`. Refusals are shown, not hidden — an answer that quietly
|
||||
dropped one would look like Buddy chose not to look. */
|
||||
outcome: string;
|
||||
rows?: number;
|
||||
detail?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** One line on an approval card: what is about to change, in checkable detail. */
|
||||
export interface ProposalDetail {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A change Buddy has resolved and is waiting on a person for.
|
||||
*
|
||||
* Nothing has happened when this arrives. The `card` is signed by the server
|
||||
* and opaque here — the console sends it back unchanged, and anything the
|
||||
* browser altered stops it verifying.
|
||||
*/
|
||||
export interface Proposal {
|
||||
summary: string;
|
||||
details?: ProposalDetail[];
|
||||
warning?: string;
|
||||
card: string;
|
||||
}
|
||||
|
||||
export interface AssistantAnswer {
|
||||
reply: string;
|
||||
used?: AssistantStep[];
|
||||
model?: string;
|
||||
/** Console routes showing the rows the answer came from. */
|
||||
sources?: string[];
|
||||
/**
|
||||
* True when the loop hit its own limits, or the model's reply was cut off
|
||||
* mid-sentence. The answer is still worth showing — a partial answer beats a
|
||||
* spinner — but it must not be presented as the whole story.
|
||||
*/
|
||||
incomplete?: boolean;
|
||||
/** Set when a change is resolved and waiting. One card, never a list. */
|
||||
awaiting?: Proposal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the assistant switched on for this deployment?
|
||||
*
|
||||
* Asked once when the panel mounts, and the answer decides whether the composer
|
||||
* is enabled. The panel has said "Not connected yet" since it was built; this is
|
||||
* what finally answers that at runtime rather than at build time.
|
||||
*
|
||||
* Never throws. A console that cannot reach this endpoint should show a
|
||||
* disabled field, not an error page — the panel is beside the work, not the
|
||||
* work itself.
|
||||
*/
|
||||
export async function assistantAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const status = await api.get<{ available?: boolean; reason?: string }>(
|
||||
`${WEB}/assistant/status`,
|
||||
);
|
||||
// The reason goes to the browser console, never to the panel. It names
|
||||
// environment variables — useful to whoever deployed this, meaningless and
|
||||
// faintly alarming to a shopkeeper. "Not connected yet" stays the only
|
||||
// thing on screen; this is how somebody finds out which variable is wrong
|
||||
// without a redeploy to add logging.
|
||||
if (status?.available !== true && status?.reason) {
|
||||
console.warn(`[nearle] Buddy is off: ${status.reason}`);
|
||||
}
|
||||
return status?.available === true;
|
||||
} catch (cause) {
|
||||
// A failed check and a configured-off server both disable the composer, and
|
||||
// both used to leave exactly the same trace: nothing. So "Not connected yet"
|
||||
// was read as "the deployment has no model" when it could equally have been
|
||||
// a 500, an expired session or a blocked request — three problems with three
|
||||
// different fixes, and no way to tell them apart from the screen.
|
||||
console.warn('[nearle] Buddy status check failed:', errorMessage(cause));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question.
|
||||
*
|
||||
* `agent` names which assistant answers — the console sends the one matching the
|
||||
* page the panel sits beside. Phase 2 ships one, so an unknown name is refused
|
||||
* rather than silently answered by the wrong agent.
|
||||
*/
|
||||
export async function askAssistant(agent: string, question: string): Promise<AssistantAnswer> {
|
||||
return api.post<AssistantAnswer>(`${WEB}/assistant/ask`, { agent, question });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a change the person has agreed to.
|
||||
*
|
||||
* A separate call with no question in it, because it is a different act: the
|
||||
* card names the action and the session names the person, and no model is
|
||||
* consulted. The server re-checks both against the live database before writing
|
||||
* — so this can legitimately fail with "somebody already approved that", which
|
||||
* is an answer rather than an error.
|
||||
*/
|
||||
export async function approveAssistant(agent: string, card: string): Promise<AssistantAnswer> {
|
||||
return api.post<AssistantAnswer>(`${WEB}/assistant/approve`, { agent, card });
|
||||
}
|
||||
@@ -6,8 +6,38 @@
|
||||
* changes. Nothing else in the app calls `fetch`.
|
||||
*/
|
||||
|
||||
import { authHeader, forgetSession, readSessionToken } from '@/auth/token';
|
||||
import type { FiestaEnvelope } from './types';
|
||||
|
||||
/**
|
||||
* A 401 on a call we authenticated means the session is over.
|
||||
*
|
||||
* Twelve hours after signing in, or the moment the signing key is rotated under
|
||||
* an open tab, every request starts coming back 401. Without this the console
|
||||
* keeps sending the dead token and each page renders its own error — which a
|
||||
* shopkeeper reads as "my data has gone", not as "sign in again". The screen
|
||||
* fills with failures and nothing tells them the one thing that would fix it.
|
||||
*
|
||||
* Only when a token was actually SENT. A 401 on an anonymous call is the
|
||||
* server declining to serve a stranger, not a session ending — and the
|
||||
* sign-in probe deliberately posts with no password to read a 401 back, so
|
||||
* reacting to that one would clear the session at the login screen and make
|
||||
* signing in impossible.
|
||||
*
|
||||
* `location.reload()` rather than a router push: the session is held in React
|
||||
* state that this module cannot reach, and a reload is the one move guaranteed
|
||||
* to land on the sign-in screen from anywhere in the app. It happens once,
|
||||
* because the storage is cleared first — the reloaded app has no token, so the
|
||||
* next 401 cannot loop.
|
||||
*/
|
||||
function endDeadSession(path: string): void {
|
||||
if (!readSessionToken()) return;
|
||||
forgetSession();
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[nearle] session rejected on ${path}; signing out`);
|
||||
if (typeof window !== 'undefined') window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Fiesta is.
|
||||
*
|
||||
@@ -133,7 +163,17 @@ interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
/**
|
||||
* One request, answered with the WHOLE envelope.
|
||||
*
|
||||
* `request` below is this plus "take the payload out", which is what nearly
|
||||
* every caller wants. A handful need a field that sits BESIDE the payload —
|
||||
* `invited` on onboarding is the one this was extracted for — and the only way
|
||||
* to read one used to be `requestEnvelope`, which does none of the checking: no
|
||||
* 401 handling, no `status: false`, no thrown `FiestaError`. So a caller that
|
||||
* wanted one extra field had to give up all the error handling to get it.
|
||||
*/
|
||||
async function send<T>(path: string, options: RequestOptions = {}): Promise<FiestaEnvelope<T>> {
|
||||
const { method = 'GET', params, body, signal } = options;
|
||||
|
||||
// There is exactly one path out of this function and it goes to `fetch`.
|
||||
@@ -147,7 +187,10 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { Accept: 'application/json' },
|
||||
// `authHeader()` is read per request, never captured: sign-in and sign-out
|
||||
// both happen while the app is running, and a header bound once would keep
|
||||
// authorising calls for whoever signed in first.
|
||||
headers: { Accept: 'application/json', ...authHeader() },
|
||||
signal: signal ?? null,
|
||||
};
|
||||
|
||||
@@ -178,6 +221,10 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
endDeadSession(path);
|
||||
}
|
||||
|
||||
if (!response.ok || envelope.status === false) {
|
||||
throw new FiestaError(
|
||||
envelope.message ?? `Request failed (HTTP ${response.status})`,
|
||||
@@ -186,10 +233,19 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
);
|
||||
}
|
||||
|
||||
// Most handlers put the payload in `details`, but a handful answer with
|
||||
// `data` instead — `products/getallproducts` and `products/create` among the
|
||||
// ones the console calls (`productController.go:400,206`). Reading only
|
||||
// `details` handed those two callers `undefined` with no error anywhere.
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload, unwrapped.
|
||||
*
|
||||
* Most handlers put it in `details`, but a handful answer with `data` instead —
|
||||
* `products/getallproducts` and `products/create` among the ones the console
|
||||
* calls (`productController.go:400,206`). Reading only `details` handed those
|
||||
* two callers `undefined` with no error anywhere.
|
||||
*/
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const envelope = await send<T>(path, options);
|
||||
return (envelope.details ?? envelope.data) as T;
|
||||
}
|
||||
|
||||
@@ -204,7 +260,7 @@ async function requestEnvelope<T>(
|
||||
const { method = 'GET', params, body } = options;
|
||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
||||
|
||||
const init: RequestInit = { method, headers: { Accept: 'application/json' } };
|
||||
const init: RequestInit = { method, headers: { Accept: 'application/json', ...authHeader() } };
|
||||
if (body !== undefined) {
|
||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
@@ -251,6 +307,16 @@ export const api = {
|
||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'POST', body, params }),
|
||||
|
||||
/**
|
||||
* A POST whose answer carries something beside the payload.
|
||||
*
|
||||
* Same checking as `post` — a failure still throws a `FiestaError` — so a
|
||||
* caller reading one extra envelope field does not give up the error handling
|
||||
* to get at it. `tenants/createtenantuser` needs `invited`.
|
||||
*/
|
||||
postEnvelope: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
send<T>(path, { method: 'POST', body, params }),
|
||||
|
||||
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||
request<T>(path, { method: 'PUT', body, params }),
|
||||
|
||||
|
||||
@@ -29,6 +29,24 @@ export interface CustomerInfo {
|
||||
deliverylocationid?: number;
|
||||
tenantlocationid?: number;
|
||||
applocationid?: number;
|
||||
/**
|
||||
* Where the customer is, as text — the column type in `app_customers`.
|
||||
*
|
||||
* Sent by `gettenantcustomers` on 97% of rows (measured across 31 customers
|
||||
* at 7 shops, 2026-09-15) and simply absent from this interface until now, so
|
||||
* the one nearly-complete piece of geography the backend has about a shop's
|
||||
* customers was invisible to every page.
|
||||
*/
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
/**
|
||||
* Empty on every customer row on the platform — 0 of 31.
|
||||
*
|
||||
* Kept declared because the column exists and a future write could fill it,
|
||||
* but nothing should render an Active/Inactive state from it: a badge that
|
||||
* reads the same on every row is decoration, and one that reads blank is
|
||||
* worse.
|
||||
*/
|
||||
status?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,23 @@ export interface RiderShift {
|
||||
shifthours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new working window.
|
||||
*
|
||||
* Times go as `HH:MM`; the server normalises `9:00` and `09:00:00` to the same
|
||||
* thing, because the dropdown labels a shift by concatenating the two columns
|
||||
* and the rows inserted by hand over the years use every spelling.
|
||||
*/
|
||||
export interface NewRiderShift {
|
||||
applocationid: number;
|
||||
shiftname?: string;
|
||||
starttime: string;
|
||||
endtime: string;
|
||||
basefare?: number;
|
||||
additionalcharges?: number;
|
||||
fuelcharge?: number;
|
||||
}
|
||||
|
||||
export const ridersApi = {
|
||||
/**
|
||||
* The directory — everyone, working today or not.
|
||||
@@ -350,6 +367,21 @@ export const ridersApi = {
|
||||
shifts: (applocationid: number) =>
|
||||
api.list<RiderShift>(`${WEB}/partners/getridershifts`, { applocationid }),
|
||||
|
||||
/**
|
||||
* Open a shift window in a region.
|
||||
*
|
||||
* A rider cannot be hired without a shift, and this table could only be read
|
||||
* until now — so a region that shipped with no shift rows was a region no
|
||||
* rider could ever be added to, from anywhere in the product. The drawer
|
||||
* showed "No shifts set up for this region" and that was the end of it.
|
||||
*
|
||||
* `shifthours` is deliberately not sent. The server works it out from the two
|
||||
* times, because it feeds rider pay and is the one field a person gets wrong
|
||||
* with nothing downstream to catch it.
|
||||
*/
|
||||
createShift: (shift: NewRiderShift) =>
|
||||
api.post<RiderShift>(`${WEB}/partners/createridershift`, shift),
|
||||
|
||||
/** Delivery partners a rider can ride for. */
|
||||
partners: (applocationid: number) =>
|
||||
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
|
||||
@@ -388,4 +420,71 @@ export const partnersApi = {
|
||||
/** The regions one partner covers. */
|
||||
locations: (partnerid: number) =>
|
||||
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
|
||||
|
||||
/**
|
||||
* Every GPS ping a partner's riders sent over a window.
|
||||
*
|
||||
* ── Scope, and why this is a platform endpoint ────────────────────────────
|
||||
*
|
||||
* It filters on `partnerid` or on the rider's `applocationid` — never on a
|
||||
* tenant. A partner's riders serve every merchant that partner supplies, so
|
||||
* there is no tenant this could be scoped to, and asking by region would hand
|
||||
* one merchant every rider in the city. That is why the fleet view lives in
|
||||
* the platform console and not in a shop's.
|
||||
*
|
||||
* ── What comes back, and what it is not ───────────────────────────────────
|
||||
*
|
||||
* One row per ping: rider, timestamp, latitude, longitude. Dense — 320,132
|
||||
* rows across eight riders for August 2026 — so a month-wide window is a
|
||||
* large response and callers ask for a day or two at a time.
|
||||
*
|
||||
* The coordinates are NOT a trail. Every row for a given rider carries the
|
||||
* same pair: one rider's 2,404 pings on 14 August 2026 all read 11.052998,
|
||||
* 76.929958, and the same holds on every day and region checked. The app
|
||||
* stamps a location once and repeats it on each heartbeat, so distance, speed
|
||||
* and "time moving" cannot be derived from this and anything of that shape
|
||||
* would be invented. Rider positions that actually move are written on the
|
||||
* `deliveries` rows; see `deliveryTrack`.
|
||||
*
|
||||
* The row also carries `login`, `logout`, `workhours`, `shorthours` and
|
||||
* `breakhours`, and every one of them is empty or zero on every row measured.
|
||||
* Nothing closes a shift. So the timestamps are what this endpoint is good
|
||||
* for — who was online and for how long — and shifts are inferred from the
|
||||
* gaps between pings; see `riderShifts`.
|
||||
*/
|
||||
riderLogs: (query: { partnerid?: number; applocationid?: number; fromdate: string; todate: string }) =>
|
||||
api.list<RiderPingRow>(`${WEB}/partners/getriderlogs`, {
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : {}),
|
||||
...(query.applocationid ? { applocationid: query.applocationid } : {}),
|
||||
fromdate: query.fromdate,
|
||||
todate: query.todate,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of `getriderlogs`.
|
||||
*
|
||||
* The shift columns are typed because they are sent, and documented as empty
|
||||
* because they are: nothing on the platform writes them. Reading `workhours`
|
||||
* and believing it is the mistake this comment exists to prevent.
|
||||
*/
|
||||
export interface RiderPingRow {
|
||||
logid?: number;
|
||||
logdate: string;
|
||||
userid: number;
|
||||
username?: string;
|
||||
partnerid?: number;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
shiftid?: number;
|
||||
shifthours?: number;
|
||||
/** Always empty on production data. See `riderLogs`. */
|
||||
login?: string;
|
||||
/** Always empty on production data. See `riderLogs`. */
|
||||
logout?: string;
|
||||
/** Always 0 on production data. See `riderLogs`. */
|
||||
workhours?: number;
|
||||
shorthours?: number;
|
||||
breakhours?: number;
|
||||
logstatus?: number;
|
||||
}
|
||||
|
||||
101
src/api/deliverySlots.ts
Normal file
101
src/api/deliverySlots.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* When a branch delivers.
|
||||
*
|
||||
* A branch offers at most three windows a day — morning, afternoon, evening —
|
||||
* and a shopper picks one at checkout. The window is a PREFERENCE, not a
|
||||
* promise: every order is accepted and no window ever fills up.
|
||||
*
|
||||
* ── A branch with no windows is not broken ──────────────────────────────────
|
||||
*
|
||||
* Every tenant trading today has none, and all of them keep taking orders.
|
||||
* An empty list means "order without a window", never "this shop is closed".
|
||||
* Nothing here may treat it as an error state.
|
||||
*
|
||||
* ── The console edits; it does not decide what is open ──────────────────────
|
||||
*
|
||||
* Whether a window is still open today is a clock comparison, and the server
|
||||
* owns it — see deliverySlotService.go. This module reads what a branch has
|
||||
* configured and writes it back. The filtered, dated list a shopper sees is an
|
||||
* app concern and is not fetched here.
|
||||
*/
|
||||
|
||||
import { api, WEB } from './client';
|
||||
|
||||
/** The three, in the order a shopper reads them. */
|
||||
export const SLOT_KEYS = ['morning', 'afternoon', 'evening'] as const;
|
||||
export type SlotKey = (typeof SLOT_KEYS)[number];
|
||||
|
||||
export interface DeliverySlot {
|
||||
deliveryslotid?: number;
|
||||
tenantid?: number;
|
||||
locationid?: number;
|
||||
slotkey: SlotKey;
|
||||
/** What the shopper reads. Blank falls back to the capitalised key. */
|
||||
name: string;
|
||||
/** "HH:MM", 24-hour, in the shop's own local time. */
|
||||
starttime: string;
|
||||
/**
|
||||
* Also the cut-off. A window takes orders right up to the moment it ends —
|
||||
* there is deliberately no separate cut-off to configure.
|
||||
*/
|
||||
endtime: string;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
/**
|
||||
* What a branch starts with when nobody has set anything.
|
||||
*
|
||||
* Seeded rather than blank so onboarding asks a shopkeeper to CONFIRM rather
|
||||
* than to invent: three empty time fields is a form most people abandon, and a
|
||||
* tenant that abandons it has a shop that cannot offer windows at all.
|
||||
*/
|
||||
export const DEFAULT_SLOTS: DeliverySlot[] = [
|
||||
{ slotkey: 'morning', name: 'Morning', starttime: '08:00', endtime: '10:00', status: 'active' },
|
||||
{ slotkey: 'afternoon', name: 'Afternoon', starttime: '12:00', endtime: '15:00', status: 'active' },
|
||||
{ slotkey: 'evening', name: 'Evening', starttime: '17:00', endtime: '20:00', status: 'active' },
|
||||
];
|
||||
|
||||
export const deliverySlotsApi = {
|
||||
/** Everything this branch has configured, active or not. */
|
||||
list: (tenantid: number, locationid: number) =>
|
||||
api.get<{ details: DeliverySlot[] }>(`${WEB}/deliveryslots`, { tenantid, locationid }),
|
||||
|
||||
/**
|
||||
* All three together, never one at a time.
|
||||
*
|
||||
* They are edited as a set on one screen, and sending them together is what
|
||||
* lets the server reject the whole edit when one row is wrong instead of
|
||||
* applying half of it — a shop with two new windows and one old one, and
|
||||
* nothing on screen saying which took, is worse than a shop with none.
|
||||
*/
|
||||
save: (tenantid: number, locationid: number, slots: DeliverySlot[]) =>
|
||||
api.put<unknown>(`${WEB}/deliveryslots`, { tenantid, locationid, slots }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Is this set fit to send?
|
||||
*
|
||||
* Mirrors the server's rules so the shopkeeper hears about a mistake while
|
||||
* their hands are still on it, rather than as a 409 after pressing save. The
|
||||
* server re-checks all of it — this is courtesy, not security.
|
||||
*/
|
||||
export function slotProblems(slots: DeliverySlot[]): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
for (const slot of slots) {
|
||||
const label = slot.name.trim() || slot.slotkey;
|
||||
|
||||
if (!/^\d{2}:\d{2}$/.test(slot.starttime) || !/^\d{2}:\d{2}$/.test(slot.endtime)) {
|
||||
problems.push(`${label} needs a start and end time.`);
|
||||
continue;
|
||||
}
|
||||
// An end at or before its start never passes the server's "still running"
|
||||
// test, so the window would simply never appear to a shopper, with nothing
|
||||
// saying why.
|
||||
if (slot.endtime <= slot.starttime) {
|
||||
problems.push(`${label} ends at or before it starts (${slot.starttime}–${slot.endtime}).`);
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
DeliveryRow,
|
||||
DeliverySummary,
|
||||
LocationOrderSummary,
|
||||
OrderItem,
|
||||
OrderRow,
|
||||
OrderSummary,
|
||||
PosLocationHealth,
|
||||
@@ -29,6 +30,18 @@ export interface OrderQuery extends DateRange {
|
||||
tenantid: number;
|
||||
/** Omit for every branch of the tenant. */
|
||||
locationid?: number;
|
||||
/**
|
||||
* One delivery partner's work, ACROSS every merchant they serve.
|
||||
*
|
||||
* The platform's own view of dispatch: a partner's riders carry for many
|
||||
* shops at once — partner 60 answered with 376 deliveries spanning 12
|
||||
* merchants — and no tenant-scoped read can show that. Verified live on
|
||||
* 2026-09-11.
|
||||
*
|
||||
* Never sent alongside a tenantid. The endpoint treats the two as separate
|
||||
* doors onto the same table, not as filters that combine.
|
||||
*/
|
||||
partnerid?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
@@ -51,7 +64,7 @@ export const insightsApi = {
|
||||
*/
|
||||
orders: (query: OrderQuery) =>
|
||||
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
|
||||
tenantid: query.tenantid,
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
@@ -61,6 +74,41 @@ export const insightsApi = {
|
||||
pagesize: query.pagesize ?? 50,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Every order in a window, not the first page of them.
|
||||
*
|
||||
* Reports totals its figures from order ROWS — `getlocationsummary` carries no
|
||||
* money and ignores the date picker, so the rows are the only source that both
|
||||
* has revenue and respects the range. Reducing over a single `pagesize: 500`
|
||||
* read made every one of those figures a silent lie the moment a tenant traded
|
||||
* more than five hundred orders in the window: the page showed a total, gave no
|
||||
* sign it was a partial one, and `api.list` discards the envelope so nothing
|
||||
* downstream could even detect the cut.
|
||||
*
|
||||
* Paging stops on a SHORT PAGE rather than on a count the list endpoint does
|
||||
* not return — the same rule `catalogue.idsByImageId` follows, and for the same
|
||||
* reason: a total we would have to trust is worse than a page we can measure.
|
||||
*
|
||||
* `maxPages` is a real bound, not a formality. Something has to stop a loop
|
||||
* pointed at production, and a window wide enough to exceed it is a window the
|
||||
* reader should be told about rather than one we quietly keep fetching. Hence
|
||||
* `truncated`, which the caller is expected to surface — the whole point of
|
||||
* this function is that a partial total never again passes for a complete one.
|
||||
*/
|
||||
ordersAll: async (
|
||||
query: OrderQuery,
|
||||
{ pagesize = 500, maxPages = 10 }: { pagesize?: number; maxPages?: number } = {},
|
||||
): Promise<{ rows: OrderRow[]; truncated: boolean }> => {
|
||||
const rows: OrderRow[] = [];
|
||||
/* `pageno` is 1-based on this endpoint — the controller floors <= 0 to 1. */
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const batch = await insightsApi.orders({ ...query, pageno: page, pagesize });
|
||||
rows.push(...batch);
|
||||
if (batch.length < pagesize) return { rows, truncated: false };
|
||||
}
|
||||
return { rows, truncated: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* The delivery jobs.
|
||||
*
|
||||
@@ -75,7 +123,7 @@ export const insightsApi = {
|
||||
*/
|
||||
deliveries: (query: OrderQuery) =>
|
||||
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
|
||||
tenantid: query.tenantid,
|
||||
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
|
||||
locationid: query.locationid,
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
@@ -116,6 +164,32 @@ export const insightsApi = {
|
||||
...range,
|
||||
}),
|
||||
|
||||
/**
|
||||
* What is actually IN an order.
|
||||
*
|
||||
* The only read that carries line items. Every list endpoint returns an
|
||||
* order's totals and never its contents, which is why the detail sheet could
|
||||
* say an order was worth ₹840 and not what the ₹840 bought.
|
||||
*
|
||||
* The envelope rather than `api.list`, because the authoritative total lives
|
||||
* outside `details`: `OrderDetail.Orderamount` is `json:"-"` on the server, so
|
||||
* `pricedetails.orderamount` is the only place it appears. Summing the lines
|
||||
* would be recomputing a figure Fiesta has already worked out, and the two
|
||||
* would disagree the first time a discount rounded differently.
|
||||
*/
|
||||
orderItems: async (orderheaderid: number) => {
|
||||
const envelope = await api.envelope<OrderItem[]>(`${WEB}/orders/getorderdetails`, {
|
||||
params: { orderheaderid },
|
||||
});
|
||||
return {
|
||||
// `details: null` for an order with no lines is as common here as `[]`;
|
||||
// see the note on `api.list`.
|
||||
items: envelope.details ?? [],
|
||||
amount: envelope.pricedetails?.orderamount ?? 0,
|
||||
tax: envelope.pricedetails?.totaltaxamount ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
deliverySummary: (tenantid: number, range: DateRange = {}) =>
|
||||
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
|
||||
import type { OrderRow } from './types';
|
||||
|
||||
/**
|
||||
@@ -22,18 +23,46 @@ import type { OrderRow } from './types';
|
||||
* does, which is the only reason its two identically-named endpoints do not
|
||||
* collide.
|
||||
*
|
||||
* ── What we deliberately do not call ────────────────────────────────────────
|
||||
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
|
||||
*
|
||||
* `optimization/riderassign` works and is useless to us: it assigns against its
|
||||
* OWN fleet. Sending our orders returned them assigned to `rider_id 883,
|
||||
* "Rajan A"` — not one of ours, and no parameter changes that. Auto-assignment
|
||||
* needs either a riders-inline variant of that endpoint or a mapping onto
|
||||
* `routemate`'s `doormile/assign`, which does accept `milers` inline. Neither
|
||||
* is wired here until somebody decides which.
|
||||
* This file used to say the opposite — that `riderassign` was useless because
|
||||
* it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours".
|
||||
* That was wrong, and it was wrong for the ordinary reason: an unfamiliar id
|
||||
* was taken for a stranger without checking the roster.
|
||||
*
|
||||
* Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and
|
||||
* so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111,
|
||||
* 1114, every one of them partner 44's, which is the Coimbatore fleet. The
|
||||
* solver reads the same database Fiesta does: `getallriders` on jupiter and
|
||||
* `getriders` on Fiesta return identical rosters and identical on-duty state.
|
||||
*
|
||||
* So auto-assignment works and `assign` below wires it up.
|
||||
*
|
||||
* ── What it cannot do yet, and why that is not our bug ──────────────────────
|
||||
*
|
||||
* The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0
|
||||
* for all 118 riders on the platform — every region, checked the same day. So
|
||||
* `active_riders_pool` is 0 and every order comes back unassigned with "No
|
||||
* riders found (check partner online status)". Supplying riders in the body
|
||||
* does not help: `riders` and `active_riders` were both tried against a rider
|
||||
* the on-duty endpoint DOES report, and the pool stayed 0.
|
||||
*
|
||||
* Whatever is meant to set `onduty` is not setting it. That is worth asking the
|
||||
* app team about; nothing here can work around it.
|
||||
*
|
||||
* ── `routemate` is gone ─────────────────────────────────────────────────────
|
||||
*
|
||||
* The old console's second mode posted to `routemate.workolik.com/api/v1/
|
||||
* optimization/riderassign?strategy=multi_trip`, which accepted a rider list
|
||||
* inline. It answers 404 now, with and without the query string, so that route
|
||||
* around the `onduty` gate is closed too.
|
||||
*/
|
||||
|
||||
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
|
||||
|
||||
/** A solve can legitimately take a while. Past this, something is wrong. */
|
||||
const SOLVE_TIMEOUT_MS = 90_000;
|
||||
|
||||
/** An order as the optimiser hands it back — ours, plus the routing it added. */
|
||||
export interface SequencedStop extends OrderRow {
|
||||
/** 1..N. The order to visit in. */
|
||||
@@ -103,6 +132,52 @@ async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
return (payload.details ?? (payload as unknown)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that is allowed to take its time, and to be cancelled.
|
||||
*
|
||||
* Separate from `post` for two reasons: the caller needs the whole envelope
|
||||
* rather than `details`, and a solve is slow enough that abandoning it has to
|
||||
* be possible. The caller's cancel and the timeout both have to be able to stop
|
||||
* it, so they are combined rather than one winning.
|
||||
*/
|
||||
async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
|
||||
const timer = new AbortController();
|
||||
const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS);
|
||||
const onAbort = () => timer.abort();
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${OPTIMISER_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: timer.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// 422 is the solver rejecting the payload and saying which field. Worth
|
||||
// showing verbatim — "422" on its own is not actionable.
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new OptimiserError(
|
||||
text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof OptimiserError) throw error;
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
throw new OptimiserError(
|
||||
signal?.aborted
|
||||
? 'Cancelled.'
|
||||
: 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.',
|
||||
);
|
||||
}
|
||||
throw new OptimiserError('Could not reach the route optimiser');
|
||||
} finally {
|
||||
clearTimeout(stop);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export const optimiserApi = {
|
||||
/**
|
||||
* Put a set of orders in a sensible order.
|
||||
@@ -132,4 +207,38 @@ export const optimiserApi = {
|
||||
*/
|
||||
reconcile: (riders: PlannedRider[]) =>
|
||||
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }),
|
||||
|
||||
/**
|
||||
* Propose a rider for each waiting order.
|
||||
*
|
||||
* A PLAN, not a commitment. Nothing is written anywhere until the operator
|
||||
* accepts it and the console makes its own `createdeliveries` call to Fiesta
|
||||
* through `buildDelivery` — the same path the manual assign bar uses, so
|
||||
* there is exactly one way a delivery is ever written. Safe to run twice and
|
||||
* safe to walk away from.
|
||||
*
|
||||
* ── Raw, not unwrapped ────────────────────────────────────────────────────
|
||||
*
|
||||
* `postRaw`, because the answer here IS the envelope: `zones` carries the
|
||||
* assignment, `meta` carries the accounting and the per-order reasons, and
|
||||
* `details` is only the flat fallback shape. `post` would hand back `details`
|
||||
* alone and throw the plan away — and `details` is `[]` on every run that
|
||||
* assigns nothing, which is every run today.
|
||||
*
|
||||
* ── Slow on purpose ───────────────────────────────────────────────────────
|
||||
*
|
||||
* Seven seconds for five orders, measured, and it is a solver so it grows
|
||||
* with the problem. `signal` is taken so a caller can offer to cancel; the
|
||||
* timeout is deliberately generous, since killing a run early abandons work
|
||||
* the operator is waiting on and teaches them the button is broken.
|
||||
*
|
||||
* `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict —
|
||||
* and the literal string `null` is a value it accepts, meaning "your default".
|
||||
*/
|
||||
assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) =>
|
||||
postRaw(
|
||||
`/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`,
|
||||
request,
|
||||
signal,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What happened to somebody's first-password invitation.
|
||||
*
|
||||
* Reported beside the person rather than folded into success or failure: they are
|
||||
* hired either way, and an unsent invitation is a task — resend, or correct the
|
||||
* address — not a hire to retry.
|
||||
*/
|
||||
export interface InviteOutcome {
|
||||
sent: boolean;
|
||||
/** Why not — an unconfigured mail host, a rejecting relay. Absent when it sent. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CreateStaffResult {
|
||||
person: StaffInfo;
|
||||
invite: InviteOutcome;
|
||||
}
|
||||
|
||||
export interface UpdateStaffRequest {
|
||||
userid: number;
|
||||
firstname?: string;
|
||||
@@ -85,7 +103,42 @@ export const staffApi = {
|
||||
unassign: (body: { tenantid: number; userid: number }) =>
|
||||
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
|
||||
|
||||
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
||||
/**
|
||||
* Hires somebody, and emails them their way in.
|
||||
*
|
||||
* The account is created with NO password — nothing on this path sets one —
|
||||
* and since the sign-in screen stopped offering to set a first password, the
|
||||
* emailed link is the only way in. So the outcome of that email comes back
|
||||
* beside the person, and the screen shows it: an invitation that did not send
|
||||
* means somebody has been added to the directory who cannot sign in, and
|
||||
* nothing else in the product would ever mention it.
|
||||
*/
|
||||
create: (body: CreateStaffRequest): Promise<CreateStaffResult> =>
|
||||
api.postEnvelope<StaffInfo>(`${WEB}/users/create`, body).then((envelope) => ({
|
||||
person: (envelope.details ?? {}) as StaffInfo,
|
||||
invite: {
|
||||
// Absent means a backend that predates the invitation — read as "not
|
||||
// sent", so a half-deployed pair cannot claim an email that never left.
|
||||
sent: envelope.invited === true,
|
||||
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
|
||||
},
|
||||
})),
|
||||
|
||||
/**
|
||||
* Sends somebody's first-password link again.
|
||||
*
|
||||
* By `userid`, not by tenant: the owner's invitation is reachable by tenantid
|
||||
* because a business has one owner, but staff and branch logins are many, so
|
||||
* an operator chasing one names the person.
|
||||
*
|
||||
* The backend refuses anybody who already has a password and says to send them
|
||||
* to the sign-in page instead. That refusal is the point — an endpoint that
|
||||
* re-issues a working password link for any account on request is a password
|
||||
* reset, and nothing here verifies identity well enough to have one. It arrives
|
||||
* as a `FiestaError` and is shown as written.
|
||||
*/
|
||||
resendInvite: (userid: number) =>
|
||||
api.post<unknown>(`${WEB}/tenants/resendinvite`, { userid }),
|
||||
|
||||
/**
|
||||
* Update a person.
|
||||
@@ -140,11 +193,23 @@ export const posUsersApi = {
|
||||
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
||||
* shop that had them. Same shape trap as `/health/location`.
|
||||
*/
|
||||
list: (tenantid: number, locationid: number) =>
|
||||
list: (tenantid: number, locationid: number, includeInactive = false) =>
|
||||
api
|
||||
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
/*
|
||||
Off by default, because that is what every existing caller assumed.
|
||||
|
||||
The listing excludes inactive accounts unless asked
|
||||
(`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
|
||||
'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
|
||||
query string. Nothing sent it, which had two consequences: a supervisor
|
||||
who switched a cashier off in the drawer watched them disappear from the
|
||||
list with no trace and no way back, and any Status column could only
|
||||
ever render "Active" because that was the only status a row could have.
|
||||
*/
|
||||
...(includeInactive ? { include_inactive: 'true' } : {}),
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
|
||||
|
||||
@@ -173,13 +238,46 @@ export const posUsersApi = {
|
||||
*
|
||||
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
|
||||
*/
|
||||
shifts: (tenantid: number, locationid: number) =>
|
||||
/**
|
||||
* The tenant's shift windows.
|
||||
*
|
||||
* `locationid` is optional and usually omitted. A shift belongs to the
|
||||
* business, so the tenant's set is what every picker in the console should
|
||||
* offer; naming a branch narrows to the tenant's plus that branch's own, for
|
||||
* the outlet that genuinely runs different hours.
|
||||
*/
|
||||
shifts: (tenantid: number, locationid?: number) =>
|
||||
api
|
||||
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
|
||||
tenantid,
|
||||
locationid,
|
||||
...(locationid ? { locationid } : {}),
|
||||
})
|
||||
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
|
||||
|
||||
/**
|
||||
* Open a shift window at one branch.
|
||||
*
|
||||
* The endpoint has existed since till staff were built; nothing in the console
|
||||
* called it. So `getstaffshifts` answered `{"shifts": []}` at every branch —
|
||||
* the comment on StoreStaffPage says exactly that — and the picker on this
|
||||
* drawer offered "Any shift" and nothing else, for everyone, permanently.
|
||||
*
|
||||
* `weekdays` is a seven-character mask starting Monday; empty means every day.
|
||||
* The server rejects anything that is not seven 0/1 characters, so it is sent
|
||||
* as the mask rather than as a list the console would have to encode twice.
|
||||
*/
|
||||
createShift: (
|
||||
tenantid: number,
|
||||
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
|
||||
/** Only for a shop that genuinely runs different hours from the business. */
|
||||
locationid?: number,
|
||||
) =>
|
||||
api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, {
|
||||
tenantid,
|
||||
// Zero means the whole tenant, which is the ordinary case.
|
||||
locationid: locationid ?? 0,
|
||||
...shift,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,6 +71,11 @@ export const productsApi = {
|
||||
* SKU lookup in `importSheetProducts` then read as a product with no
|
||||
* `productid`: every sheet import resolved zero ids and wrote no locations
|
||||
* and no stock. Flattened here so no caller sees the grouping.
|
||||
*
|
||||
* Nothing calls this today — the importer that did now gets its ids from the
|
||||
* create response. Kept because it is the only wrapper for a real endpoint
|
||||
* and the grouping above is the sort of thing the next caller would be
|
||||
* caught by all over again.
|
||||
*/
|
||||
allProducts: (tenantid: number) =>
|
||||
api
|
||||
@@ -93,7 +98,35 @@ export const productsApi = {
|
||||
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
||||
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
|
||||
|
||||
/** Single product only — the backend parses one object, not an array. */
|
||||
/**
|
||||
* Turn one product's health score on or off for this shop.
|
||||
*
|
||||
* The merchant's call, not Nearle's. The score comes from a third party
|
||||
* matching a reference product by name — often under 60% confidence — and a
|
||||
* shopkeeper holding the packet is better placed than that matcher to say
|
||||
* whether the rating describes what they sell.
|
||||
*
|
||||
* Hides the SCORE only. The nutrition table is unaffected.
|
||||
*/
|
||||
setShowHealthScore: (tenantid: number, productid: number, showhealthscore: boolean) =>
|
||||
api.put<unknown>(`${WEB}/products/showhealthscore`, {
|
||||
tenantid,
|
||||
productid,
|
||||
showhealthscore,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Single product only — the backend parses one object, not an array.
|
||||
*
|
||||
* Answers with the created row, `productid` included. It used to echo back
|
||||
* the request body, which meant `productid: 0` every time: the id is
|
||||
* assigned by the database and nothing read it back. Callers that needed it
|
||||
* — and pricing and stocking a product both do — had to re-read the
|
||||
* catalogue and find their own row again by SKU.
|
||||
*
|
||||
* The payload arrives under `data` rather than `details`, which the client
|
||||
* already handles.
|
||||
*/
|
||||
createProduct: (product: Partial<Product>) =>
|
||||
api.post<Product>(`${WEB}/products/create`, product),
|
||||
|
||||
@@ -267,9 +300,12 @@ export async function importSheetProducts(
|
||||
const subcategoryIdFor = (row: SheetProductRow): number =>
|
||||
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
|
||||
|
||||
const locationRows: ProductLocationRequest[] = [];
|
||||
const stockRows: ProductStockRequest[] = [];
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
try {
|
||||
await productsApi.createProduct({
|
||||
const created = await productsApi.createProduct({
|
||||
tenantid,
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
@@ -286,51 +322,61 @@ export async function importSheetProducts(
|
||||
productdesc: row.productdesc,
|
||||
productstatus: 'Active',
|
||||
});
|
||||
|
||||
/*
|
||||
The id comes back from the create now.
|
||||
|
||||
This loop used to collect SKUs, then read the tenant's ENTIRE catalogue
|
||||
back, build a SKU→product map and match its own rows against it, because
|
||||
`POST /products/create` answered `productid: 0`. That is fixed on the
|
||||
backend — the id is the database's and it is returned — so the second
|
||||
read and the matching are both gone.
|
||||
|
||||
Worth saying what the old way actually cost, because it was not only the
|
||||
extra request. Matching on SKU means matching on a column nothing
|
||||
enforces: this importer creates duplicates on re-upload by design, and
|
||||
the map kept the LAST row for a SKU, so a second upload sent the new
|
||||
product's price and stock to whichever copy happened to win. A row whose
|
||||
SKU was blank, or trimmed differently by the sheet, could not be found at
|
||||
all and was reported as "Created, but could not be found again by SKU" —
|
||||
a message about the console's own bookkeeping that a shop could do
|
||||
nothing with.
|
||||
|
||||
A zero here would be worse than the old behaviour, so it is checked
|
||||
rather than assumed: the product exists either way, and saying so is more
|
||||
use than silently pricing product 0.
|
||||
*/
|
||||
if (!created?.productid) {
|
||||
failures.push({
|
||||
row,
|
||||
reason: 'Created, but the server did not return its id — price and stock were not set',
|
||||
});
|
||||
onProgress?.(index + 1, rows.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
createdSkus.push(row.productsku);
|
||||
locationRows.push({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: created.productid,
|
||||
price: row.retailprice,
|
||||
status: 'available',
|
||||
});
|
||||
stockRows.push({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: created.productid,
|
||||
quantity: row.quantity,
|
||||
stocktype: 'in',
|
||||
status: 'Active',
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
|
||||
}
|
||||
onProgress?.(index + 1, rows.length);
|
||||
}
|
||||
|
||||
if (createdSkus.length === 0) {
|
||||
return { created: 0, linked: 0, stocked: 0, failures };
|
||||
}
|
||||
|
||||
// Resolve the ids the create endpoint refused to hand back.
|
||||
const all = await productsApi.allProducts(tenantid);
|
||||
const bySku = new Map<string, Product>();
|
||||
for (const product of all) {
|
||||
if (product.productsku) bySku.set(product.productsku, product);
|
||||
}
|
||||
|
||||
const locationRows: ProductLocationRequest[] = [];
|
||||
const stockRows: ProductStockRequest[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!createdSkus.includes(row.productsku)) continue;
|
||||
const product = bySku.get(row.productsku);
|
||||
if (!product) {
|
||||
failures.push({ row, reason: 'Created, but could not be found again by SKU' });
|
||||
continue;
|
||||
}
|
||||
locationRows.push({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: product.productid,
|
||||
price: row.retailprice,
|
||||
status: 'available',
|
||||
});
|
||||
stockRows.push({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: product.productid,
|
||||
quantity: row.quantity,
|
||||
stocktype: 'in',
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
|
||||
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
|
||||
|
||||
|
||||
135
src/api/routing.ts
Normal file
135
src/api/routing.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Real road geometry between two points.
|
||||
*
|
||||
* ── Why a third service ─────────────────────────────────────────────────────
|
||||
*
|
||||
* A straight line between two drops is not a route. On a map it cuts through
|
||||
* blocks and across rivers, and its length is not the distance anybody rode —
|
||||
* so a line drawn that way invites a measurement it cannot support. OSRM
|
||||
* returns the actual road path, which is both honest and immediately readable
|
||||
* as "they went round the one-way system".
|
||||
*
|
||||
* `router.project-osrm.org` is the project's own demo server. Verified reachable
|
||||
* 2026-09-10 (200 in ~1.1 s for a Coimbatore leg). It is a courtesy service with
|
||||
* no SLA and a fair-use policy, which shapes everything below: legs are cached,
|
||||
* requests are capped per draw, and a failure is silent because a map that
|
||||
* loses its road geometry is still a useful map.
|
||||
*
|
||||
* ── Failure is expected and must be cheap ───────────────────────────────────
|
||||
*
|
||||
* Every caller takes back a path or null, never an error. A null means "draw the
|
||||
* straight line instead", which is what the map already did. Nothing about a
|
||||
* dispatch board should break because a free routing server was busy.
|
||||
*/
|
||||
|
||||
const OSRM_BASE = (
|
||||
import.meta.env?.['VITE_OSRM_BASE'] ?? 'https://router.project-osrm.org'
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
/** One leg's road geometry, as [lat, lng] pairs ready for a polyline. */
|
||||
export type RoadPath = { lat: number; lng: number }[];
|
||||
|
||||
export interface Leg {
|
||||
from: { lat: number; lng: number };
|
||||
to: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legs already fetched, keyed on their rounded endpoints.
|
||||
*
|
||||
* Module-level and unbounded on purpose within a session: a dispatch board
|
||||
* redraws constantly — every filter, every poll — and the same legs recur. Two
|
||||
* hundred legs of geometry is a few hundred kilobytes, and re-fetching them
|
||||
* from a courtesy server on each render is the behaviour that gets an IP
|
||||
* blocked.
|
||||
*/
|
||||
const cache = new Map<string, RoadPath | null>();
|
||||
|
||||
/** Five decimal places is about a metre — finer than any two drops differ by. */
|
||||
function keyOf(leg: Leg): string {
|
||||
return `${leg.from.lat.toFixed(5)},${leg.from.lng.toFixed(5)};${leg.to.lat.toFixed(5)},${leg.to.lng.toFixed(5)}`;
|
||||
}
|
||||
|
||||
/** A single leg's road path, or null when it cannot be had. */
|
||||
async function fetchLeg(leg: Leg, signal?: AbortSignal): Promise<RoadPath | null> {
|
||||
const url =
|
||||
`${OSRM_BASE}/route/v1/driving/` +
|
||||
`${leg.from.lng},${leg.from.lat};${leg.to.lng},${leg.to.lat}` +
|
||||
`?overview=full&geometries=geojson`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json()) as {
|
||||
routes?: { geometry?: { coordinates?: [number, number][] } }[];
|
||||
};
|
||||
const coordinates = body.routes?.[0]?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
// GeoJSON is [lng, lat]; leaflet wants lat first. Getting this backwards
|
||||
// puts Coimbatore in the Arabian Sea, which is the classic symptom.
|
||||
return coordinates.map(([lng, lat]) => ({ lat, lng }));
|
||||
} catch {
|
||||
// Includes the abort. A cancelled draw wants no path, same as a failed one.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many legs one draw may ask for.
|
||||
*
|
||||
* A hundred-drop round is ninety-nine legs, and asking a courtesy server for
|
||||
* all of them at once is how a shared IP gets rate-limited for everybody. Past
|
||||
* this the map falls back to straight lines, which it can always draw.
|
||||
*/
|
||||
const MAX_LEGS_PER_DRAW = 60;
|
||||
|
||||
/** How many requests are in flight at once. Polite, and enough to feel instant. */
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
export const routingApi = {
|
||||
/**
|
||||
* Road geometry for a set of legs.
|
||||
*
|
||||
* Returns a map keyed the same way the caller can look up — `keyFor(leg)` —
|
||||
* holding a path or null per leg. Cached legs cost nothing; uncached ones are
|
||||
* fetched a few at a time.
|
||||
*
|
||||
* Never throws. A leg that could not be routed is absent from the result and
|
||||
* the caller draws its straight line, which is what it did before.
|
||||
*/
|
||||
roads: async (legs: readonly Leg[], signal?: AbortSignal): Promise<Map<string, RoadPath>> => {
|
||||
const out = new Map<string, RoadPath>();
|
||||
const wanted: Leg[] = [];
|
||||
|
||||
for (const leg of legs) {
|
||||
const key = keyOf(leg);
|
||||
if (cache.has(key)) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) out.set(key, hit);
|
||||
} else if (wanted.length < MAX_LEGS_PER_DRAW) {
|
||||
wanted.push(leg);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < wanted.length; i += CONCURRENCY) {
|
||||
if (signal?.aborted) break;
|
||||
const batch = wanted.slice(i, i + CONCURRENCY);
|
||||
const paths = await Promise.all(batch.map((leg) => fetchLeg(leg, signal)));
|
||||
batch.forEach((leg, index) => {
|
||||
const key = keyOf(leg);
|
||||
const path = paths[index] ?? null;
|
||||
// Null is cached too: a leg the router cannot do will not start working
|
||||
// if it is asked sixty more times this session.
|
||||
cache.set(key, path);
|
||||
if (path) out.set(key, path);
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
/** The key a leg's path is stored under, for callers reading the result. */
|
||||
keyFor: keyOf,
|
||||
};
|
||||
112
src/api/telemetry.ts
Normal file
112
src/api/telemetry.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Where a rider is right now, and how their phone is doing.
|
||||
*
|
||||
* ── This is a different backend, and that is the whole point ────────────────
|
||||
*
|
||||
* `jupiter.nearle.app` — the platform's older API, which the xpress console
|
||||
* runs against. Fiesta has no equivalent: `getriderperiodiclogs` does not exist
|
||||
* there under any prefix, and `partners/getriderlogs`, the closest-looking
|
||||
* Fiesta endpoint, is a heartbeat log that repeats one fixed coordinate per
|
||||
* rider all day (see `riderShifts`). So this is the ONLY live rider position
|
||||
* on the platform, and it was missed once already by checking Fiesta alone.
|
||||
*
|
||||
* Verified 2026-09-10: rider 852 polled thirty seconds apart moved about 140 m,
|
||||
* with battery, connection and accuracy all changing. Genuinely live.
|
||||
*
|
||||
* Same database as Fiesta underneath — `getallriders` on jupiter and
|
||||
* `getriders` on Fiesta return identical rosters and identical on-duty state —
|
||||
* so a userid from one is a userid in the other.
|
||||
*
|
||||
* ── One rider per call ──────────────────────────────────────────────────────
|
||||
*
|
||||
* There is no fleet form: `?userid=N` answers for that rider, and omitting it
|
||||
* answers for whichever rider reported most recently, which is not useful. A
|
||||
* fleet view therefore fans out, which is fine at the sizes involved — a
|
||||
* merchant's round is a handful of riders.
|
||||
*
|
||||
* ── Freshness is the caller's problem, and must be shown ────────────────────
|
||||
*
|
||||
* The endpoint always answers, and it answers with the LAST known fix however
|
||||
* old. Riders 883, 897 and 1111 come back with positions from 5, 3 and 12 days
|
||||
* ago and nothing in the payload flags them as stale. A map that draws those
|
||||
* next to a live one is lying, so `logdate` is parsed here and every consumer
|
||||
* is handed an age rather than a bare position.
|
||||
*/
|
||||
|
||||
const JUPITER_BASE = (
|
||||
import.meta.env?.['VITE_JUPITER_BASE'] ?? 'https://jupiter.nearle.app'
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
/** One rider's live snapshot, exactly as jupiter sends it. */
|
||||
export interface RiderSnapshot {
|
||||
userid: number;
|
||||
username?: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
/** `2026-09-10 11:25:11` — server local time, no zone. */
|
||||
logdate?: string;
|
||||
/** `"95%"`, with the sign. */
|
||||
battery?: string;
|
||||
/** `mobile`, `wifi`, `none`. */
|
||||
connection?: string;
|
||||
/** Metres of GPS uncertainty, as a string. 100.0 is a poor fix. */
|
||||
accuracy?: string;
|
||||
/** Km/h and degrees, both as strings. */
|
||||
speed?: string;
|
||||
heading?: string;
|
||||
/** `idle`, `active`, and whatever else the app decides to send. */
|
||||
status?: string;
|
||||
/** The order they are on, when they are on one. */
|
||||
orderid?: string;
|
||||
is_charging?: boolean;
|
||||
is_background?: boolean;
|
||||
/** `enabled` / `disabled` — a disabled one explains a stale position. */
|
||||
location_service?: string;
|
||||
}
|
||||
|
||||
export class TelemetryError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TelemetryError';
|
||||
}
|
||||
}
|
||||
|
||||
export const telemetryApi = {
|
||||
/**
|
||||
* One rider's latest reported position and phone state.
|
||||
*
|
||||
* Never throws for "this rider has never reported" — that answers 200 with an
|
||||
* empty-ish body, and the caller wants to draw the rider as unreachable
|
||||
* rather than show an error. It throws only when jupiter itself cannot be
|
||||
* reached, which is a different thing and worth saying out loud: jupiter can
|
||||
* be down while Fiesta is fine, and vice versa.
|
||||
*/
|
||||
rider: async (userid: number, signal?: AbortSignal): Promise<RiderSnapshot | null> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
`${JUPITER_BASE}/live/api/v1/utils/getriderperiodiclogs?userid=${userid}`,
|
||||
{ headers: { Accept: 'application/json' }, signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') throw error;
|
||||
throw new TelemetryError(
|
||||
'Could not reach the live rider service. It is a separate backend from the rest of the console, so everything else keeps working.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TelemetryError(`The live rider service answered ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { data?: RiderSnapshot }
|
||||
| null;
|
||||
const data = payload?.data;
|
||||
// A rider who has never opened the app comes back without coordinates.
|
||||
// Null rather than an empty object, so "no fix" is one check everywhere.
|
||||
return data && (data.latitude || data.longitude) ? data : null;
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { api, WEB } from './client';
|
||||
import type { AppLocation } from './deliveries';
|
||||
import type { InviteOutcome } from './people';
|
||||
import type { TenantInfo, TenantLocation } from './types';
|
||||
|
||||
/** Everything the tenant-onboarding form collects. */
|
||||
@@ -44,6 +45,31 @@ export interface CreateTenantRequest {
|
||||
/** Everything the branch-onboarding form collects. */
|
||||
export interface CreateBranchRequest {
|
||||
tenantid: number;
|
||||
/**
|
||||
* The delivery region, inherited from the tenant's existing outlets.
|
||||
*
|
||||
* `tenantlocations.applocationid` has no column default, so a create that
|
||||
* omits it stores 0 — and `resolveOfflineLocationContext` in
|
||||
* `orderRepository.go` calls this column "authoritative", with no fallback
|
||||
* anywhere for a zero. It is also copied straight onto the login the backend
|
||||
* spawns for the branch, so the outlet AND the person running it both end up
|
||||
* in no region at all.
|
||||
*
|
||||
* Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
|
||||
* 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
|
||||
*/
|
||||
applocationid?: number;
|
||||
/**
|
||||
* Also inherited, and also without a column default.
|
||||
*
|
||||
* `orderRepository.go` documents the consequence in its own comment —
|
||||
* "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
|
||||
* orders nonetheless use non-zero values" — and works around it by copying
|
||||
* the scaffolding off the most recent real order at that outlet. A branch
|
||||
* commissioned five minutes ago has no such order, so the workaround has
|
||||
* nothing to copy and the joins are left to resolve against a zero.
|
||||
*/
|
||||
moduleid?: number;
|
||||
locationname: string;
|
||||
email?: string;
|
||||
contactno?: string;
|
||||
@@ -76,6 +102,20 @@ export interface CreateBranchRequest {
|
||||
operatorid?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A commissioned branch, and what happened to its operator's invitation.
|
||||
*
|
||||
* The branch exists either way. A login that was not emailed is a task for
|
||||
* whoever commissioned it — resend, or fix the address — and not a branch to
|
||||
* create again.
|
||||
*/
|
||||
export interface CreateBranchResult {
|
||||
branch: TenantLocation;
|
||||
invite: InviteOutcome;
|
||||
/** The login the branch spawned, for a resend. 0 when a person was placed. */
|
||||
operatorUserid: number;
|
||||
}
|
||||
|
||||
export interface TenantListQuery {
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
@@ -151,9 +191,24 @@ export const tenantsApi = {
|
||||
* created row, and the new `locationid` is what a QR code and every
|
||||
* follow-up write need. `createlocation` answers 201 with a message and no
|
||||
* `details` at all.
|
||||
*
|
||||
* `invite` reports the spawned login's first-password email. It is `sent:
|
||||
* false` with NO reason when an `operatorid` was named — nothing was created,
|
||||
* so there was nothing to send, and that is not a failure to report.
|
||||
*/
|
||||
createBranch: (body: CreateBranchRequest) =>
|
||||
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
|
||||
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
|
||||
api
|
||||
.postEnvelope<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body)
|
||||
.then((envelope) => ({
|
||||
branch: (envelope.details ?? {}) as TenantLocation,
|
||||
invite: {
|
||||
sent: envelope.invited === true,
|
||||
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
|
||||
},
|
||||
// The spawned login, so a failed invitation can be resent without
|
||||
// hunting for the row by eye. 0 when an existing person was placed.
|
||||
operatorUserid: envelope.inviteuserid ?? 0,
|
||||
})),
|
||||
|
||||
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
|
||||
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
||||
|
||||
237
src/api/types.ts
237
src/api/types.ts
@@ -33,6 +33,83 @@ export interface FiestaEnvelope<T> {
|
||||
data?: T;
|
||||
/** Present on the tenant login endpoints. */
|
||||
tenantform?: boolean;
|
||||
/**
|
||||
* The console session, issued by the login endpoints only.
|
||||
*
|
||||
* Sits beside `details` rather than inside it because it is not a fact about
|
||||
* the user — it is what proves the request is theirs. Every later call sends
|
||||
* it as `Authorization: Bearer`, and `middleware.WebAuth` reads the tenant out
|
||||
* of it instead of believing the one on the query string.
|
||||
*/
|
||||
token?: string;
|
||||
/** Unix seconds. The tab closing normally ends the session well before this. */
|
||||
tokenexpiresat?: number;
|
||||
/**
|
||||
* Order totals, from `orders/getorderdetails` only.
|
||||
*
|
||||
* Beside `details` rather than inside it because the line items are a list
|
||||
* and this is one figure about the order as a whole. `OrderDetail.Orderamount`
|
||||
* is tagged `json:"-"` on the server, so the authoritative total exists HERE
|
||||
* and nowhere else in the response — summing the lines is an approximation of
|
||||
* a number Fiesta has already worked out.
|
||||
*/
|
||||
pricedetails?: { orderamount?: number; totaltaxamount?: number };
|
||||
/**
|
||||
* Whether a newly created account was emailed its first-password link.
|
||||
* On the three endpoints that create one: `users/create`,
|
||||
* `tenants/createstaff` and `tenants/createtenantlocation`.
|
||||
*
|
||||
* Beside `details` rather than inside it because it is not a fact about the
|
||||
* person or the branch — those exist either way. It is what happened to a
|
||||
* separate side effect, and the only one an operator can act on.
|
||||
*/
|
||||
invited?: boolean;
|
||||
/** Why it did not send. Absent when it did. */
|
||||
invitereason?: string;
|
||||
/**
|
||||
* Who to resend to, from `tenants/createtenantlocation` only.
|
||||
*
|
||||
* `details` there is the branch row, and the login the branch spawned lives in
|
||||
* `app_users` — so this is the only way to name it. 0 when an existing person
|
||||
* was placed and no account was created.
|
||||
*/
|
||||
inviteuserid?: number;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Order line items — models/order.go:OrderDetail
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* One product on an order.
|
||||
*
|
||||
* Returned by `orders/getorderdetails`, which is the ONLY read that carries
|
||||
* them — the list endpoints give an order's totals and never what is in it.
|
||||
* That is why every screen showing an order has, until now, been able to say
|
||||
* what it cost but not what it was.
|
||||
*/
|
||||
export interface OrderItem {
|
||||
orderdetailid: number;
|
||||
orderheaderid: number;
|
||||
productid: number;
|
||||
productname: string;
|
||||
productdescription?: string;
|
||||
/** What was ordered. A float because some products sell by weight. */
|
||||
orderqty: number;
|
||||
/** What the shop could actually supply — less than `orderqty` is a short fill. */
|
||||
supplyqty?: number;
|
||||
price: number;
|
||||
unitname?: string;
|
||||
taxamount?: number;
|
||||
discountamount?: number;
|
||||
/** The line total as Fiesta computed it: quantity, tax and discount applied. */
|
||||
productsumprice?: number;
|
||||
itemstatus?: string;
|
||||
/**
|
||||
* Always empty today. The column is `gorm:"-"` on the server, so it is
|
||||
* serialised and never populated — do not build a thumbnail on it.
|
||||
*/
|
||||
productimage?: string;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -118,6 +195,19 @@ export interface TenantInfo {
|
||||
/** Capitalised on the wire. */
|
||||
Accountname?: string;
|
||||
status: string;
|
||||
/**
|
||||
* How many outlets this merchant has.
|
||||
*
|
||||
* Sent by `getalltenants` only, so it is optional — every other endpoint
|
||||
* returning a `TenantInfo` leaves it out.
|
||||
*
|
||||
* The store list used to work this out for itself, by counting how many
|
||||
* times a tenantid appeared, on the belief that the endpoint returned one
|
||||
* row per tenant-location pair. It returns one row per tenant and always
|
||||
* has, so the count was always 1 and the platform's "Branches" total was
|
||||
* really its tenant total.
|
||||
*/
|
||||
branchcount?: number;
|
||||
}
|
||||
|
||||
export interface TenantLocation {
|
||||
@@ -207,10 +297,37 @@ export interface Product {
|
||||
/** Capitalised on the wire. */
|
||||
Subcategoryname?: string;
|
||||
catalogueid?: number;
|
||||
/**
|
||||
* Whether this shop shows the product's health score.
|
||||
*
|
||||
* The merchant's decision, made at import and changeable from the product.
|
||||
* Optional because a backend that predates the column sends nothing — and an
|
||||
* absent value means YES, which is what every product showed before this
|
||||
* existed.
|
||||
*
|
||||
* Gates the SCORE only. The nutrition figures are shown either way: they are
|
||||
* what the label states, while the score is a judgement of them.
|
||||
*/
|
||||
showhealthscore?: boolean;
|
||||
productname?: string;
|
||||
productimage?: string;
|
||||
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
||||
productimages?: string;
|
||||
/**
|
||||
* What the global catalogue said about this product when it was imported —
|
||||
* a JSON-encoded object, held as a string. Parse with `catalogueFactsOf`.
|
||||
*
|
||||
* Carries the fields the product table has no columns for: the FSSAI
|
||||
* licence, nutrition, highlights, providers, the typical retail range and
|
||||
* the variant key. The import used to drop all of them and the drawer went
|
||||
* back to the catalogue on every open, which stopped working the moment a
|
||||
* re-scrape retired the source row — taking a licence number off a product
|
||||
* the shop was still selling.
|
||||
*
|
||||
* Absent on anything that did not come from the catalogue. The drawer still
|
||||
* falls back to the live lookup for those.
|
||||
*/
|
||||
cataloguefacts?: string;
|
||||
productdesc?: string;
|
||||
productsku?: string;
|
||||
brandid?: number;
|
||||
@@ -300,6 +417,17 @@ export interface ImportCatalogueProductRequest {
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
taxpercent: number;
|
||||
/**
|
||||
* Whether this shop will show the product's health score.
|
||||
*
|
||||
* Omitted means yes. The backend reads an absent field and an explicit
|
||||
* `false` as different answers on purpose, so a caller that does not know
|
||||
* about this does not have its imports read as a deliberate no.
|
||||
*
|
||||
* Only the score. The nutrition figures are sent either way — they are what
|
||||
* the label states, while the score is a judgement of them.
|
||||
*/
|
||||
showhealthscore?: boolean;
|
||||
}
|
||||
|
||||
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
|
||||
@@ -329,23 +457,53 @@ export interface ProductStockRequest {
|
||||
Orders & deliveries — the summary shapes the console reads
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface OrderSummary {
|
||||
totalorders?: number;
|
||||
delivered?: number;
|
||||
/**
|
||||
* What `getordersummary` and `getlocationsummary` actually send.
|
||||
*
|
||||
* ── These two used to declare fields the backend has never sent ─────────────
|
||||
*
|
||||
* Both carried `totalorders?: number` and `revenue?: number`. Neither exists in
|
||||
* either response. Verified live on 2026-09-11 against tenant 1147 — the whole
|
||||
* body is:
|
||||
*
|
||||
* {"total":168,"created":84,"pending":1,"processing":0,
|
||||
* "delivered":1,"cancelled":82,"locationid":1185,"locationname":"R mart "}
|
||||
*
|
||||
* The count is `total`, not `totalorders`, and there is no money on it at all.
|
||||
* Because both were optional AND the interface had an `[key: string]: unknown`
|
||||
* index signature, `summary.revenue ?? 0` type-checked perfectly and evaluated
|
||||
* to 0 forever. Four pages read those two names — Reports, both Consoles and
|
||||
* the platform's store detail — so every "App revenue" and "Online orders"
|
||||
* figure on this console read ₹0 / 0 against a shop with 84 real orders worth
|
||||
* ₹25,728 in the same range.
|
||||
*
|
||||
* The index signature is gone with them. It is what let the names drift from
|
||||
* the wire in the first place: with it, a typo and a renamed column are both
|
||||
* legal, and neither shows up until someone looks at a screen and sees a zero.
|
||||
*
|
||||
* Revenue is not here because it is not available here. It comes from the order
|
||||
* rows — see `branchOrderStats` in `features/store-admin/branchStats.ts`.
|
||||
*/
|
||||
interface OrderCounts {
|
||||
/** Every order in scope, whatever its status. The other counts sum to this. */
|
||||
total?: number;
|
||||
created?: number;
|
||||
pending?: number;
|
||||
processing?: number;
|
||||
delivered?: number;
|
||||
cancelled?: number;
|
||||
revenue?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LocationOrderSummary {
|
||||
export interface OrderSummary extends OrderCounts {
|
||||
tenantid?: number;
|
||||
tenantname?: string;
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
}
|
||||
|
||||
export interface LocationOrderSummary extends OrderCounts {
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
totalorders?: number;
|
||||
delivered?: number;
|
||||
cancelled?: number;
|
||||
revenue?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DeliverySummary {
|
||||
@@ -445,8 +603,17 @@ export interface OrderRow {
|
||||
*/
|
||||
ordervalue?: number;
|
||||
orderamount?: number;
|
||||
/** Cash to collect on delivery. Shown only when > 0. */
|
||||
collectionamt?: number;
|
||||
/*
|
||||
* `collectionamt` was declared here and read by three screens. It does not
|
||||
* exist in Fiesta — `grep -rn "collectionamt" --include=*.go` returns nothing
|
||||
* — so every one of them showed a dash or zero from the day it was written.
|
||||
* Removed 2026-09-28 along with those screens.
|
||||
*
|
||||
* A type can describe a field the server has never sent, and nothing catches
|
||||
* it: the optional marker makes `undefined` legal, and `?? 0` turns it into a
|
||||
* plausible figure. That is the trap, and it is worth remembering before the
|
||||
* next optional money field is added on the strength of a field name.
|
||||
*/
|
||||
deliverycharge?: number;
|
||||
deliveryamt?: number;
|
||||
paymenttype?: number;
|
||||
@@ -518,6 +685,24 @@ export interface OrderRow {
|
||||
* filters the whole list away — the drop address is the real test.
|
||||
*/
|
||||
deliverytype?: string;
|
||||
|
||||
/**
|
||||
* The delivery window the customer chose, and the day it falls on.
|
||||
*
|
||||
* Absent on every order placed before windows shipped, and on every order
|
||||
* from a branch that has set none — which is most of them. Treat absence as
|
||||
* "no window was asked for", never as a problem with the order.
|
||||
*
|
||||
* Distinct from `deliverytime` above, which is a timestamp of what happened.
|
||||
* These two say what was asked for.
|
||||
*/
|
||||
deliveryslotid?: number;
|
||||
deliveryslotdate?: string;
|
||||
/** Joined from `deliveryslots`, so a renamed window reads correctly on old orders. */
|
||||
slotkey?: string;
|
||||
deliveryslotname?: string;
|
||||
deliveryslotstart?: string;
|
||||
deliveryslotend?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -732,6 +917,19 @@ export interface DeliveryRow {
|
||||
deliverytime?: string;
|
||||
canceltime?: string;
|
||||
expecteddeliverytime?: string;
|
||||
|
||||
/**
|
||||
* The delivery window the customer asked for.
|
||||
*
|
||||
* Joined from the ORDER — `deliveries` has no window of its own, on purpose,
|
||||
* so there is one truth about what was asked for. Absent on most rows.
|
||||
*/
|
||||
deliveryslotid?: number;
|
||||
deliveryslotdate?: string;
|
||||
slotkey?: string;
|
||||
deliveryslotname?: string;
|
||||
deliveryslotstart?: string;
|
||||
deliveryslotend?: string;
|
||||
itemcount?: number;
|
||||
orderamount?: number;
|
||||
pickupcustomer?: string;
|
||||
@@ -823,6 +1021,19 @@ export interface StaffInfo {
|
||||
locationid?: number;
|
||||
locationname?: string;
|
||||
status?: string;
|
||||
/**
|
||||
* Whether they have ever chosen a password.
|
||||
*
|
||||
* Every person here is created with an empty one and emailed a link to set it.
|
||||
* Until that link is used they are in this list, in every branch picker, and
|
||||
* cannot sign in — and `status` does not say so, because an Active account with
|
||||
* no password is refused at the login screen like any other.
|
||||
*
|
||||
* So `false` is the row that needs an action: a lost invitation, waiting to be
|
||||
* sent again. Optional because a backend that predates the column sends
|
||||
* nothing, and the directory then simply does not make the claim.
|
||||
*/
|
||||
issetup?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,19 @@ export interface SessionUser {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
issuperadmin: boolean;
|
||||
/**
|
||||
* The signed session, from the login response.
|
||||
*
|
||||
* Optional, and that is the rollout rather than an oversight: a console built
|
||||
* against a Fiesta that does not issue tokens yet stores nothing here and
|
||||
* keeps working exactly as before. It becomes required when
|
||||
* `WEB_AUTH_REQUIRED` is switched on server-side.
|
||||
*
|
||||
* Everything else on this record describes the user. This one is the only
|
||||
* field the server will not take the console's word for — which is the whole
|
||||
* point of it.
|
||||
*/
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser {
|
||||
* sub-path is absorbed there and never reaches the global one.
|
||||
*/
|
||||
export const HOME_ROUTE: Record<ConsoleRole, string> = {
|
||||
'nearle-admin': '/nearle/stores',
|
||||
// `/nearle/stores` is not a route in this application any more — Nearle's own
|
||||
// staff have their own, `nearle-platform`. Pointing at it would be exactly
|
||||
// the failure the comment above describes: the router sends an unknown path
|
||||
// to `*`, `*` sends it back here, and React Router resolves the loop by
|
||||
// rendering nothing — a blank page with no console error.
|
||||
//
|
||||
// It cannot be reached today, because `login` and `restore` both refuse this
|
||||
// role before a session exists. It is `/login` rather than that unreachable
|
||||
// path so that if one of those two checks is ever weakened, the result is a
|
||||
// sign-in screen rather than a white screen nobody can diagnose.
|
||||
'nearle-admin': '/login',
|
||||
'store-admin': '/admin/console',
|
||||
'store-manager': '/store/console',
|
||||
};
|
||||
|
||||
112
src/auth/session.test.ts
Normal file
112
src/auth/session.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import { persist, restore, clear } from './session';
|
||||
import { SESSION_STORAGE_KEY } from './token';
|
||||
import type { SessionUser } from './roles';
|
||||
|
||||
/*
|
||||
A session without a token is not a session.
|
||||
|
||||
On 2026-09-25 a PUT to `users/update` came back 401 and the request carried no
|
||||
`authorization` header at all. The console was signed in by every visible
|
||||
measure — name in the corner, nav rendered, pages mounted — and could not make
|
||||
one authenticated request, because `restore()` accepted a stored blob that had a
|
||||
`userid` and a `role` and no token.
|
||||
|
||||
Two ways in. A session stored before the token existed; or a login against a
|
||||
server that could not mint one — `attachWebSession` logs that failure and
|
||||
returns the user record anyway, which was right while WEB_AUTH_REQUIRED was off
|
||||
and is a broken console now that it defaults on.
|
||||
|
||||
Either way the state is the same and it is the worst one available: authorised
|
||||
enough to render, not enough to load anything, and nothing on screen saying so.
|
||||
*/
|
||||
|
||||
// A minimal sessionStorage, since node has none.
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
};
|
||||
|
||||
const signedIn: SessionUser = {
|
||||
userid: 904,
|
||||
role: 'store-admin',
|
||||
token: 'w1.payload.signature',
|
||||
} as SessionUser;
|
||||
|
||||
beforeEach(() => store.clear());
|
||||
|
||||
test('a stored session with a token comes back', () => {
|
||||
persist(signedIn);
|
||||
assert.equal(restore()?.userid, 904);
|
||||
assert.equal(restore()?.token, 'w1.payload.signature');
|
||||
});
|
||||
|
||||
test('a session with no token is refused', () => {
|
||||
// The bug. Restoring this renders a console that cannot load anything.
|
||||
store.set(SESSION_STORAGE_KEY, JSON.stringify({ userid: 904, role: 'store-admin' }));
|
||||
assert.equal(restore(), null, 'a tokenless session was restored');
|
||||
});
|
||||
|
||||
test('a session with an empty token is refused', () => {
|
||||
// `token: ""` is what a server that failed to mint would produce if the field
|
||||
// were assigned rather than spread. Same broken state, different shape.
|
||||
store.set(SESSION_STORAGE_KEY, JSON.stringify({ userid: 904, role: 'store-admin', token: ' ' }));
|
||||
assert.equal(restore(), null, 'an empty token was accepted');
|
||||
});
|
||||
|
||||
test('the checks that were already there still hold', () => {
|
||||
// A corrupted blob must not crash the shell on boot.
|
||||
for (const bad of [
|
||||
'{"role":"store-admin","token":"t"}',
|
||||
'{"userid":904,"token":"t"}',
|
||||
'{"userid":"904","role":"store-admin","token":"t"}',
|
||||
'not json at all',
|
||||
'',
|
||||
]) {
|
||||
store.set(SESSION_STORAGE_KEY, bad);
|
||||
assert.equal(restore(), null, `restored from ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('signing out leaves nothing behind', () => {
|
||||
persist(signedIn);
|
||||
clear();
|
||||
assert.equal(restore(), null);
|
||||
});
|
||||
|
||||
/*
|
||||
The wrong console.
|
||||
|
||||
Nearle staff sign in at the platform site, merchants at the merchant one, and
|
||||
neither accepts the other's accounts. The role is not known until the password
|
||||
has been checked, so the refusal happens after credentials are verified — which
|
||||
makes the ORDER of the refusal and the write the thing worth pinning.
|
||||
|
||||
`persist` used to run before anything else could object. A refusal after it
|
||||
would leave a valid session on this origin belonging to somebody with no routes
|
||||
to reach: signed in by every measure the shell uses, with a nav built from a
|
||||
role this build does not serve, and no way out except clearing storage by hand.
|
||||
*/
|
||||
|
||||
test('a session for the other console is not restored', () => {
|
||||
// These tests run as the merchant build, so a Nearle staff session is the
|
||||
// wrong one. It reaches storage when a build's workspace flag changes under a
|
||||
// session that was valid when it was written.
|
||||
store.set(
|
||||
SESSION_STORAGE_KEY,
|
||||
JSON.stringify({ userid: 1, role: 'nearle-admin', token: 'w1.a.b' }),
|
||||
);
|
||||
assert.equal(restore(), null, 'a platform session was restored on the merchant console');
|
||||
});
|
||||
|
||||
test('the consoles own roles are still restored', () => {
|
||||
// The refusal must not be so broad that it locks out the people this site is
|
||||
// for. Both merchant roles keep working.
|
||||
for (const role of ['store-admin', 'store-manager']) {
|
||||
store.set(SESSION_STORAGE_KEY, JSON.stringify({ userid: 1, role, token: 'w1.a.b' }));
|
||||
assert.equal(restore()?.role, role, `${role} was refused on its own console`);
|
||||
}
|
||||
});
|
||||
@@ -1,25 +1,56 @@
|
||||
/**
|
||||
* Sign-in and session persistence.
|
||||
*
|
||||
* There is no token to hold. `TenantWebLogin` returns the user record and
|
||||
* nothing else, so the session IS that record. It is kept in sessionStorage
|
||||
* rather than localStorage: a shared back-office machine should not stay signed
|
||||
* in after the browser closes, and there is no server-side session to revoke.
|
||||
* The session is the user record plus, now, a signed token. Until Fiesta grew
|
||||
* `middleware.WebAuth` there was no token to hold: login returned the record and
|
||||
* nothing else, the console asserted its own `tenantid` on every request, and
|
||||
* the server believed it. The record is still what the app renders from; the
|
||||
* token is the only part the server will not take our word for.
|
||||
*
|
||||
* Kept in sessionStorage rather than localStorage: a shared back-office machine
|
||||
* should not stay signed in after the browser closes. That also means the tab
|
||||
* closing is what normally ends a session — the token's own expiry is a
|
||||
* backstop for a tab left open, not the mechanism.
|
||||
*
|
||||
* The storage key lives in `./token`, which the HTTP client also reads. It has
|
||||
* to sit under both: this file calls the API to sign in, and the client needs
|
||||
* the token to make that call authorised, so neither can import the other.
|
||||
*/
|
||||
|
||||
import { api, WEB } from '@/api/client';
|
||||
import type { FiestaUser } from '@/api/types';
|
||||
import { toSessionUser, type SessionUser } from './roles';
|
||||
import { SESSION_STORAGE_KEY } from './token';
|
||||
import { isAllowedHere, wrongConsoleMessage } from './workspace';
|
||||
|
||||
const STORAGE_KEY = 'nearle.session.v1';
|
||||
/**
|
||||
* Thrown when the credentials were right but the account belongs to the other
|
||||
* console.
|
||||
*
|
||||
* Its own type so the login screen can present it as an answer rather than a
|
||||
* failure: nothing went wrong, the person is at the wrong door. It reads
|
||||
* differently from "that password is not right", and showing it in the same red
|
||||
* as a bad password would send somebody to reset a password that is fine.
|
||||
*/
|
||||
export class WrongConsoleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WrongConsoleError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the account exists but has never had a password set. */
|
||||
/**
|
||||
* Thrown when the account exists but has never had a password set.
|
||||
*
|
||||
* It carried the userid, for the setup form that used to be on the login screen.
|
||||
* Both are gone: a userid was all it took to set any account's password, and the
|
||||
* probe below handed one to anybody who typed an email. The signed invitation
|
||||
* replaced it, so there is nothing left for this to carry.
|
||||
*/
|
||||
export class PasswordSetupRequiredError extends Error {
|
||||
readonly userid: number;
|
||||
constructor(userid: number) {
|
||||
constructor() {
|
||||
super('This account needs a password before it can sign in.');
|
||||
this.name = 'PasswordSetupRequiredError';
|
||||
this.userid = userid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,23 +87,61 @@ const CONFIG_ID = 1;
|
||||
export async function login(email: string, password: string): Promise<SessionUser> {
|
||||
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
|
||||
|
||||
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
|
||||
`${WEB}/users/applogin`,
|
||||
{ method: 'POST', body },
|
||||
);
|
||||
const envelope = await api.envelope<FiestaUser & { setup?: boolean }>(`${WEB}/users/applogin`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
// A brand-new account — `createtenantlocation` spawns branch logins with an
|
||||
// empty password — answers `status: true` with a 409 and the userid to set
|
||||
// one against. It is not a failure, it is the first step.
|
||||
// empty password — answers `status: true` with a 409. Not a failure: the
|
||||
// account is real and is waiting for its invitation to be used.
|
||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
||||
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
|
||||
throw new PasswordSetupRequiredError();
|
||||
}
|
||||
|
||||
if (envelope.status !== true || !envelope.details) {
|
||||
throw new Error(loginMessage(envelope.code, envelope.message));
|
||||
}
|
||||
|
||||
const session = toSessionUser(envelope.details);
|
||||
// The token rides on the envelope, not on `details` — it is not a fact about
|
||||
// the user, it is what proves a later request is theirs.
|
||||
//
|
||||
// Refused when absent, rather than stored and hoped for. `attachWebSession`
|
||||
// on the server logs a minting failure and returns the user record anyway,
|
||||
// which was correct while WEB_AUTH_REQUIRED was off and is a broken console
|
||||
// now that it defaults on: the sign-in succeeds, the shell renders, and every
|
||||
// request after it goes out with no `authorization` header and comes back
|
||||
// 401 with nothing on screen to say why.
|
||||
//
|
||||
// Failing here names the problem at the moment it happens, to the person best
|
||||
// placed to report it, instead of scattering 401s across every page.
|
||||
if (!envelope.token) {
|
||||
throw new Error(
|
||||
'Signed in, but the server did not issue a session. Nothing would load — ' +
|
||||
'tell your administrator the API could not mint a session token.',
|
||||
);
|
||||
}
|
||||
|
||||
const session = { ...toSessionUser(envelope.details), token: envelope.token };
|
||||
|
||||
/*
|
||||
* The wrong console, refused before anything is written down.
|
||||
*
|
||||
* Nearle's staff sign in at the platform site and merchants at the merchant
|
||||
* one, and neither may sign in at the other. The role is not known until the
|
||||
* password has been checked — `applogin` returns it — so the refusal can only
|
||||
* happen here, after credentials are verified and before a session exists.
|
||||
*
|
||||
* The ORDER is the whole point. `persist` used to run first, so refusing
|
||||
* afterwards would leave a valid session on this origin belonging to somebody
|
||||
* with no routes to reach: signed in by every measure the shell uses, with a
|
||||
* nav built from a role this build does not serve. Throwing before the write
|
||||
* leaves storage untouched and the login screen exactly as it was.
|
||||
*/
|
||||
if (!isAllowedHere(session.role)) {
|
||||
throw new WrongConsoleError(wrongConsoleMessage(session.role));
|
||||
}
|
||||
|
||||
persist(session);
|
||||
return session;
|
||||
}
|
||||
@@ -99,7 +168,7 @@ export async function login(email: string, password: string): Promise<SessionUse
|
||||
*
|
||||
* 409 + status false → no such account ("Invalid Email")
|
||||
* 403 → account deactivated
|
||||
* 409 + status true → exists, no password set (carries the userid)
|
||||
* 409 + status true → exists, no password set
|
||||
* 401 + status true → exists, has a password ("Password is required")
|
||||
*
|
||||
* The last one is the whole trick: a password-less attempt against a real
|
||||
@@ -112,16 +181,29 @@ export async function login(email: string, password: string): Promise<SessionUse
|
||||
* password, so the probe reveals nothing that was not already available with
|
||||
* one more field filled in.
|
||||
*/
|
||||
export type AccountCheck = { state: 'password' } | { state: 'setup'; userid: number };
|
||||
/**
|
||||
* `setup` no longer carries a userid, and that is the point.
|
||||
*
|
||||
* It used to, and the pair of that and `setpassword` taking a bare userid was
|
||||
* an account takeover: this probe answers an email with NO password, so anyone
|
||||
* could POST a merchant's primary address — usually printed on their shopfront
|
||||
* — receive their userid, set a password and own the business. The server has
|
||||
* stopped returning it and stopped accepting it.
|
||||
*
|
||||
* So `setup` now means only "this account exists and has never been used". The
|
||||
* way in is the invitation emailed at onboarding, and the screen says so rather
|
||||
* than offering a form.
|
||||
*/
|
||||
export type AccountCheck = { state: 'password' } | { state: 'setup' };
|
||||
|
||||
export async function checkAccount(email: string): Promise<AccountCheck> {
|
||||
const envelope = await api.envelope<{ setup?: boolean; userid?: number }>(
|
||||
`${WEB}/users/applogin`,
|
||||
{ method: 'POST', body: { authname: email.trim(), configid: CONFIG_ID } },
|
||||
);
|
||||
const envelope = await api.envelope<{ setup?: boolean }>(`${WEB}/users/applogin`, {
|
||||
method: 'POST',
|
||||
body: { authname: email.trim(), configid: CONFIG_ID },
|
||||
});
|
||||
|
||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
||||
return { state: 'setup', userid: envelope.details.userid ?? 0 };
|
||||
return { state: 'setup' };
|
||||
}
|
||||
// "Password is required" — the account is real and has one. Exactly what we
|
||||
// wanted to learn, arriving as a refusal.
|
||||
@@ -137,27 +219,48 @@ export const MIN_PASSWORD_LENGTH = 6;
|
||||
/**
|
||||
* Sets the password on an account that has never had one.
|
||||
*
|
||||
* `PUT /users/update` doubles as the password call. There is no dedicated
|
||||
* endpoint and no reset flow — the controller says so in as many words
|
||||
* (`userController.go:145`): "this endpoint also doubles as the
|
||||
* password-setup/reset call (userid + password only, everything else left zero
|
||||
* so GORM's `Updates` skips it)". Sending only those two fields is therefore
|
||||
* load-bearing: a struct with any other field populated would write it.
|
||||
* `POST /users/setpassword`, which is public — it has to be. This runs when
|
||||
* nobody is signed in and cannot be: the account has no password yet, so there
|
||||
* is no way to obtain a session first.
|
||||
*
|
||||
* This is reachable only with the `userid` that `applogin` just handed back for
|
||||
* an account it confirmed has an empty password. It is not a "change my
|
||||
* password" call and must not be wired up as one — nothing here verifies the
|
||||
* old password, because there is no old password.
|
||||
* It used to call `PUT /users/update`, which doubles as a password write but
|
||||
* sits behind the session guard. Once `WEB_AUTH_REQUIRED` began defaulting on,
|
||||
* that returned "a session token is required; sign in again" to somebody who
|
||||
* could not sign in — sign-in needs a password, and setting the password needed
|
||||
* a sign-in. Every branch login created with an empty password was unusable.
|
||||
*
|
||||
* The server refuses this on any account that already HAS a password, which is
|
||||
* what makes leaving it open safe. It is a setup call, never a reset — nothing
|
||||
* here verifies an old password, because there is no old password.
|
||||
*
|
||||
* ── Why it takes a token and not a userid ───────────────────────────────────
|
||||
*
|
||||
* It took a userid, which `checkAccount` obtained by POSTing an email with no
|
||||
* password. That pair was an account takeover: read a merchant's primary
|
||||
* address off their shopfront, POST it, receive their userid, set a password,
|
||||
* own the business. No guessing at any step, and the empty-password check was
|
||||
* no defence — an un-set-up account is exactly what such an attacker wants.
|
||||
*
|
||||
* The invitation emailed at onboarding replaced it. The userid lives inside a
|
||||
* signature the server produced, so knowing an email is no longer enough and
|
||||
* neither is knowing a userid. This is now reached only from `/set-password`,
|
||||
* with a token out of the link.
|
||||
*
|
||||
* Passwords are stored in clear on this backend. That is not something the
|
||||
* console can fix, and it is the reason this flow exists at all rather than an
|
||||
* emailed setup link.
|
||||
* console can fix.
|
||||
*/
|
||||
export async function setInitialPassword(userid: number, password: string): Promise<void> {
|
||||
export async function setInitialPassword(token: string, password: string): Promise<void> {
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
|
||||
}
|
||||
await api.put<unknown>(`${WEB}/users/update`, { userid, password });
|
||||
if (!token.trim()) {
|
||||
// Reached when somebody opens /set-password with no `t` in the URL — a
|
||||
// truncated link, or a copy that lost the query string. Said here so the
|
||||
// screen can explain it rather than the server answering "not an
|
||||
// invitation link" to a request that was never going to work.
|
||||
throw new Error('This link is incomplete. Use the full link from your invitation email.');
|
||||
}
|
||||
await api.post<unknown>(`${WEB}/users/setpassword`, { token, password });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,17 +287,43 @@ function loginMessage(code: number | undefined, message: string | undefined): st
|
||||
}
|
||||
|
||||
export function persist(session: SessionUser): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function restore(): SessionUser | null {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SessionUser;
|
||||
// A stored blob is only as trustworthy as the tab it came from; a shape
|
||||
// check keeps a corrupted value from crashing the shell on boot.
|
||||
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
|
||||
// A session without a token is not a session.
|
||||
//
|
||||
// This used to be restored happily, and the result was the worst state the
|
||||
// console can be in: signed in by every visible measure — name in the
|
||||
// corner, nav rendered, pages mounted — and unable to make a single
|
||||
// authenticated request, because `authHeader()` had nothing to send. Every
|
||||
// call came back 401 and nothing on screen explained why. A PUT to
|
||||
// `users/update` on 2026-09-25 went out with no `authorization` header at
|
||||
// all, which is what sent us looking.
|
||||
//
|
||||
// It happens whenever login could not mint one: `attachWebSession` logs the
|
||||
// failure and returns the user record anyway, which was right while
|
||||
// WEB_AUTH_REQUIRED was off and is a broken console now that it defaults on.
|
||||
// A stored session predating the token also lands here.
|
||||
//
|
||||
// Returning null sends them to the sign-in screen, which is a state people
|
||||
// know what to do with.
|
||||
if (typeof parsed.token !== 'string' || parsed.token.trim() === '') return null;
|
||||
// The same refusal as sign-in, applied to what is already in storage.
|
||||
//
|
||||
// A session stored before the two consoles were split, or one held on a
|
||||
// site whose workspace flag has since changed, belongs to somebody this
|
||||
// build serves no routes for. Restoring it renders a shell with a nav built
|
||||
// from a role that has nowhere to go — so it is dropped and they are asked
|
||||
// to sign in, which is where they learn which console is theirs.
|
||||
if (!isAllowedHere(parsed.role)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -202,5 +331,5 @@ export function restore(): SessionUser | null {
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
81
src/auth/token.ts
Normal file
81
src/auth/token.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Where the session token is kept, and how the HTTP client reaches it.
|
||||
*
|
||||
* A module of its own, holding nothing but the storage key and a reader,
|
||||
* because the two files that need it cannot import each other: `session.ts`
|
||||
* calls the API to sign in, and `client.ts` needs the token to make that same
|
||||
* API call authorised. Anything shared between them has to sit underneath both.
|
||||
*
|
||||
* It imports nothing, on purpose — that is what keeps it free of the cycle.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The single owner of this key.
|
||||
*
|
||||
* `session.ts` writes the blob and `client.ts` reads one field out of it, and a
|
||||
* second copy of the string is how those two quietly stop agreeing after a
|
||||
* rename.
|
||||
*/
|
||||
export const SESSION_STORAGE_KEY = 'nearle.session.v1';
|
||||
|
||||
/**
|
||||
* The signed session on this tab, if there is one.
|
||||
*
|
||||
* Reads storage on every call rather than caching. Sign-in and sign-out both
|
||||
* happen while the app is running, and a cached token would keep authorising
|
||||
* requests for a user who has just left — or send nothing for one who has just
|
||||
* arrived, until a reload.
|
||||
*
|
||||
* Returns undefined for every failure, including a throw. `sessionStorage`
|
||||
* raises rather than returning null in a browser with site data blocked, and a
|
||||
* console that cannot read a token should make an unauthenticated request and
|
||||
* be refused by the server, not fail to render.
|
||||
*/
|
||||
export function readSessionToken(): string | undefined {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return undefined;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
||||
const token = (parsed as { token?: unknown }).token;
|
||||
return typeof token === 'string' && token !== '' ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Authorization` header for a request, or nothing at all.
|
||||
*
|
||||
* Nothing, rather than an empty or `Bearer null` header, when there is no
|
||||
* session. A header that is present but meaningless is worse than an absent
|
||||
* one: `middleware.WebAuth` refuses a token that does not verify whatever the
|
||||
* enforcement flag says, so sending rubbish would turn every anonymous call
|
||||
* into a 401 — including the ones that are still meant to work while the
|
||||
* rollout is in progress.
|
||||
*/
|
||||
export function authHeader(): Record<string, string> {
|
||||
const token = readSessionToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this tab's session, because the server says the token is no longer good.
|
||||
*
|
||||
* A session expires after twelve hours, and the signing key can be rotated
|
||||
* under a tab that is still open. Without this the console goes on sending a
|
||||
* dead token and every page shows an error — which reads to the person as "my
|
||||
* data has gone", not as "sign in again".
|
||||
*
|
||||
* Lives here rather than in `session.ts` for the same reason the reader does:
|
||||
* the HTTP client is what learns a session is dead, and it cannot import the
|
||||
* module that calls it.
|
||||
*/
|
||||
export function forgetSession(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage throws where site data is blocked. Nothing to do: the next read
|
||||
// returns undefined either way, which is the state this wanted.
|
||||
}
|
||||
}
|
||||
74
src/auth/workspace.test.ts
Normal file
74
src/auth/workspace.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
|
||||
import type { ConsoleRole } from './roles';
|
||||
|
||||
/*
|
||||
Who may sign in to the merchant console.
|
||||
|
||||
Merchants and their branch users sign in here. Nearle's own staff have their own
|
||||
application — `nearle-platform`, a separate repository with its own deploy — and
|
||||
a staff account is turned away rather than redirected: no session crosses to
|
||||
another site half-made.
|
||||
|
||||
The role is not known until the password has been checked, so the refusal
|
||||
happens after credentials are verified. What matters is that it happens BEFORE
|
||||
the session is written: `login` refuses first and persists second, and `restore`
|
||||
applies the same check to what is already in storage. A staff member who typed a
|
||||
correct password must not end up signed in here with no routes to reach.
|
||||
*/
|
||||
|
||||
const ROLES: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
|
||||
|
||||
/** The role this console turns away, used by the wording tests below. */
|
||||
const REFUSED_ROLE: ConsoleRole = 'nearle-admin';
|
||||
|
||||
test('this build is the merchant console, with nothing to configure', () => {
|
||||
// A constant, not a build flag. The flag existed only while one codebase
|
||||
// served both consoles; a variable now would be a way to deploy this
|
||||
// application as something it is not.
|
||||
assert.equal(WORKSPACE, 'merchant');
|
||||
});
|
||||
|
||||
test('both merchant roles are admitted and Nearle staff are not', () => {
|
||||
assert.equal(isAllowedHere('store-admin'), true);
|
||||
assert.equal(isAllowedHere('store-manager'), true);
|
||||
assert.equal(isAllowedHere('nearle-admin'), false);
|
||||
});
|
||||
|
||||
test('every role is decided, none left to a default', () => {
|
||||
// A role added later must be listed deliberately. Falling through to allowed
|
||||
// would quietly admit it to the merchant console.
|
||||
const admitted = ROLES.filter(isAllowedHere);
|
||||
assert.deepEqual(admitted, ['store-admin', 'store-manager']);
|
||||
});
|
||||
|
||||
test('the refusal names the platform console rather than blaming the account', () => {
|
||||
const message = wrongConsoleMessage('nearle-admin');
|
||||
|
||||
assert.match(message, /platform\.nearledaily\.com/);
|
||||
// The password was right and the account is fine. Anyone told "failed" or
|
||||
// "denied" goes and resets a working password, or asks an administrator to
|
||||
// fix nothing at all.
|
||||
for (const blame of ['failed', 'invalid', 'denied', 'not recognised', 'wrong password']) {
|
||||
assert.ok(!message.toLowerCase().includes(blame), `the refusal reads as a fault: ${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the refused role is named in words a person uses', () => {
|
||||
assert.match(wrongConsoleMessage('nearle-admin'), /Nearle staff/);
|
||||
});
|
||||
|
||||
test('the refusal tells them what to do, not what a category of account does', () => {
|
||||
// "Store admin accounts sign in at…" is a true statement about accounts in
|
||||
// general. "That is a Store admin account" is about the one in the box in
|
||||
// front of them, and the second sentence is an instruction rather than a
|
||||
// description. Both consoles use this shape, because a person sees one of
|
||||
// them and the pair should not read as two different products.
|
||||
const message = wrongConsoleMessage(REFUSED_ROLE);
|
||||
|
||||
assert.match(message, /^That is a .+ account\. Sign in at .+\.$/);
|
||||
// Two sentences, not a paragraph. This is read by somebody who expected to be
|
||||
// inside the application by now.
|
||||
assert.ok(message.length < 90, `too long to read at a glance: ${message}`);
|
||||
});
|
||||
75
src/auth/workspace.ts
Normal file
75
src/auth/workspace.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ConsoleRole } from './roles';
|
||||
|
||||
/**
|
||||
* Who may sign in to the merchant console.
|
||||
*
|
||||
* ── Why this is a constant ──────────────────────────────────────────────────
|
||||
*
|
||||
* There was a `VITE_WORKSPACE` build flag here while one codebase served both
|
||||
* consoles and had to be told which it was. That is over: Nearle's own staff
|
||||
* have their own application, `nearle-platform`, and this repository is the
|
||||
* merchant console and nothing else. A flag would only be a way to deploy this
|
||||
* application as something it is not.
|
||||
*
|
||||
* ── The separation is a refusal, not a redirect ─────────────────────────────
|
||||
*
|
||||
* A Nearle staff account is turned away here with a sentence naming where it
|
||||
* belongs. It is not bounced to the other site carrying a half-made session.
|
||||
*
|
||||
* The role is not known until the password has been checked — `applogin`
|
||||
* returns it — so the refusal can only happen after credentials are verified.
|
||||
* `login` applies it BEFORE the session is written and `restore` applies it to
|
||||
* what is already stored. That order is the point: a session persisted first
|
||||
* and refused afterwards leaves somebody signed in by every measure the shell
|
||||
* uses, with a nav built from a role this application serves no routes for.
|
||||
*/
|
||||
export const WORKSPACE = 'merchant' as const;
|
||||
|
||||
const ALLOWED: ReadonlySet<ConsoleRole> = new Set<ConsoleRole>(['store-admin', 'store-manager']);
|
||||
|
||||
export function isAllowedHere(role: ConsoleRole): boolean {
|
||||
return ALLOWED.has(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform console's address, for the sentence shown to a staff member who
|
||||
* signs in at the wrong site.
|
||||
*
|
||||
* A build variable rather than a constant, because the two are separate
|
||||
* deployments and either can move. The fallback is the production hostname,
|
||||
* which is right far more often than saying nothing would be.
|
||||
*/
|
||||
const PLATFORM_HOST =
|
||||
(import.meta.env?.['VITE_PLATFORM_HOST'] ?? '').trim() || 'platform.nearledaily.com';
|
||||
|
||||
/**
|
||||
* Names the host rather than linking to it. A live link from one sign-in screen
|
||||
* to another reads as a redirect that failed, and this is not a failure — it is
|
||||
* the right answer to the wrong door.
|
||||
*
|
||||
* Both consoles say this the same way — "That is a X account. Sign in at Y." —
|
||||
* because a person only ever sees one of them and the pair should not read as
|
||||
* two different products. The earlier versions did: one opened "This is the
|
||||
* Nearle platform console…", the other closed with "…not here."
|
||||
*
|
||||
* It addresses what they typed rather than describing a category of account.
|
||||
* "Store admin accounts sign in at…" is true of accounts in general; "That is a
|
||||
* Store admin account" is about the one in the box in front of them.
|
||||
*/
|
||||
export function wrongConsoleMessage(role: ConsoleRole): string {
|
||||
return `That is a ${roleWord(role)} account. Sign in at ${PLATFORM_HOST}.`;
|
||||
}
|
||||
|
||||
function roleWord(role: ConsoleRole): string {
|
||||
switch (role) {
|
||||
case 'nearle-admin':
|
||||
return 'Nearle staff';
|
||||
// Unreachable: both merchant roles are allowed here, so neither reaches the
|
||||
// refusal. Present because the switch is exhaustive over the union and a
|
||||
// missing arm would be a type error the day a role is added.
|
||||
case 'store-admin':
|
||||
return 'Store admin';
|
||||
case 'store-manager':
|
||||
return 'Store user';
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
margin: '48px auto',
|
||||
maxWidth: 620,
|
||||
padding: '28px 32px',
|
||||
borderRadius: 16,
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: '1px solid #F1D3D3',
|
||||
background: '#FFFBFB',
|
||||
fontFamily: 'inherit',
|
||||
@@ -85,8 +85,19 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
||||
{/*
|
||||
In DEVELOPMENT nothing was deployed, and saying so sends a developer
|
||||
looking for a release that never happened. The same failure has a
|
||||
different cause here: the dev server restarted, or Vite re-optimised
|
||||
its dependencies after a config change, and either invalidates the
|
||||
module URLs this tab is holding. Same fix, different sentence —
|
||||
`import.meta.env.DEV` is compiled out of the production bundle, so
|
||||
this costs a deployed console nothing.
|
||||
*/}
|
||||
{isStale
|
||||
? 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
|
||||
? import.meta.env.DEV
|
||||
? 'The dev server restarted or re-optimised its dependencies, so the module URL this tab was holding no longer resolves. Nothing is wrong with your code — reloading picks up the current module graph.'
|
||||
: 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
|
||||
: 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}
|
||||
</p>
|
||||
<pre
|
||||
@@ -109,7 +120,10 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
onClick={isStale ? this.reload : this.reset}
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 999,
|
||||
/* The same corner as every other control on the console. A stadium
|
||||
here made the one button a person sees on a broken screen the one
|
||||
button shaped unlike the rest of the product. */
|
||||
borderRadius: 'var(--card-radius-sm, 10px)',
|
||||
border: '1px solid #D9C0C0',
|
||||
background: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
|
||||
@@ -1,107 +1,89 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import './kpiCard.css';
|
||||
|
||||
/**
|
||||
* A KPI tile — the console's headline figure.
|
||||
*
|
||||
* ── There were two of these, and neither worked ─────────────────────────────
|
||||
*
|
||||
* This component put an icon inline with the label and CENTRED the value
|
||||
* underneath it, which is why a row of tiles never lined up: a centred number
|
||||
* sits in a different place in every tile depending on how long it is.
|
||||
* `console.css` had a second, unrelated implementation — `.kpi` — with a
|
||||
* brand-tinted icon tile on the left and the text stacked beside it. The two
|
||||
* appeared on the same screen. The second was the better design and this is
|
||||
* now that design, for both.
|
||||
*
|
||||
* ── `note` is rendered again, and that is the real change ───────────────────
|
||||
*
|
||||
* The line under the figure — "142 app · 38 counter", "12% of app orders" —
|
||||
* was removed from the tile at some point but never removed from the CALLERS:
|
||||
* 86 of them still compute and pass it, and it was being thrown away. That is
|
||||
* the half of a KPI that carries the meaning. A bare `0` under "Unsynced
|
||||
* bills" reads as "no data" when it means everything is in the books, and `42`
|
||||
* under "Total orders" says nothing about which channel they came through.
|
||||
*
|
||||
* ── `tone` does something now ───────────────────────────────────────────────
|
||||
*
|
||||
* All five tones used to map to `var(--color-brand)`, so the prop was
|
||||
* decorative — every tile was purple whether it reported takings or a failure.
|
||||
* The tone now colours the icon tile only: the figure itself stays in ink,
|
||||
* because a number that changes colour with its own value is hard to compare
|
||||
* against the tile beside it. Brand remains the default, so a tile that is
|
||||
* merely reporting is not shouting.
|
||||
*/
|
||||
|
||||
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
|
||||
|
||||
const TONE_COLOR: Record<KpiTone, string> = {
|
||||
accent: 'var(--color-brand)',
|
||||
success: 'var(--color-brand)',
|
||||
warning: 'var(--color-brand)',
|
||||
error: 'var(--color-brand)',
|
||||
neutral: 'var(--color-brand)',
|
||||
};
|
||||
|
||||
export interface KpiCardProps {
|
||||
/** Small-caps label. Say what it is, not what it means. */
|
||||
label: string;
|
||||
value: string;
|
||||
/**
|
||||
* The sub-pill. This is where the number gets its meaning — "1 of 84 orders"
|
||||
* says something "1.2%" does not.
|
||||
* The line under the figure — what the number is made of, or what it is a
|
||||
* share of. This is where a KPI gets its meaning; see the note above.
|
||||
*/
|
||||
note?: string;
|
||||
/**
|
||||
* Colours the icon tile. `neutral` and `accent` are both the brand — a tile
|
||||
* reports by default and only says more when something is actually wrong.
|
||||
*/
|
||||
tone?: KpiTone;
|
||||
icon?: ReactNode;
|
||||
/**
|
||||
* Accepted and ignored.
|
||||
*
|
||||
* The underline bar this used to drive was removed from the tile. Six call
|
||||
* sites still pass it, so the prop stays declared to keep them compiling —
|
||||
* it is not read. Either drop it from the callers or restore the bar; right
|
||||
* now it is neither, and this comment is here so that is visible.
|
||||
* The 2px underline bar this drove was removed from the tile deliberately —
|
||||
* a proportion bar under five tiles turned the strip into a chart. Six call
|
||||
* sites still pass it, so the prop stays declared to keep them compiling.
|
||||
* Drop it from those callers and this can go.
|
||||
*/
|
||||
fill?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A KPI tile.
|
||||
*
|
||||
* Label and icon, a large tabular value, a muted sub-pill carrying the
|
||||
* interpretation, and a 2px underline in the tile's tone. Sized to sit 4–6
|
||||
* across rather than 4 — a console is glanced at all day, not read once.
|
||||
*/
|
||||
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
|
||||
const color = TONE_COLOR[tone];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
|
||||
{/* Icon first, then the label; the pill sits opposite it. Both are
|
||||
fixed to this row so tiles line up whether or not they carry one. */}
|
||||
{/* A fixed height, so a label that wraps to two lines (a long note
|
||||
squeezes it) does not push that tile's value below its neighbours'. */}
|
||||
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
|
||||
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
|
||||
{icon ? (
|
||||
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<div className="kpi-card" data-tone={tone}>
|
||||
{/* Label and icon share the top row; the figure gets the full width of
|
||||
the card underneath them.
|
||||
|
||||
{note ? (
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
maxWidth: '58%',
|
||||
background: 'var(--color-slate-100)',
|
||||
color: 'var(--color-slate-500)',
|
||||
borderRadius: 12,
|
||||
padding: '2px 8px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</span>
|
||||
) : null}
|
||||
</HStack>
|
||||
The icon used to sit in a column of its own to the LEFT, and at the
|
||||
width these tiles actually get — five across a 1,224px column, less
|
||||
340px whenever Nearle Buddy is open — a 40px tile plus its gutter
|
||||
left about 134px for the number. "₹1,24,500" does not fit in 134px at
|
||||
24px bold, so it wrapped, and it wrapped mid-figure: the first tile
|
||||
on the Console read "₹1,24,50" on one line and "0" on the next. */}
|
||||
<div className="kpi-card-head">
|
||||
<span className="kpi-card-label">{label}</span>
|
||||
{icon ? (
|
||||
<span className="kpi-card-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<HStack justify="center" align="center" style={{ flex: 1 }}>
|
||||
<Text
|
||||
as="div"
|
||||
hasTabularNumbers
|
||||
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
</Card>
|
||||
<span className="kpi-card-value">{value}</span>
|
||||
{note ? <span className="kpi-card-note">{note}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { StickyRow } from './StickyRow';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -39,9 +40,19 @@ export interface PageHeaderProps {
|
||||
*
|
||||
* The tabs' spacing lives here rather than at each call site, so the five pages
|
||||
* that have tabs cannot drift apart from each other again.
|
||||
*
|
||||
* ── Held under the nav bar ──────────────────────────────────────────────────
|
||||
*
|
||||
* This row does not scroll away. It used to: reaching the bottom of a long list
|
||||
* put the tab you were in, the search and every action button off the top of
|
||||
* the window, so the only way back to the controls over what you were reading
|
||||
* was to scroll back through all of it. The page still scrolls normally —
|
||||
* nothing here gets a scrollbar of its own — the row simply stays. The
|
||||
* behaviour and the canvas it paints live in `StickyRow` and `.page-sticky`.
|
||||
*/
|
||||
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
|
||||
const hasRow = Boolean(actions || (isTabsInline && tabs));
|
||||
const hasTabsRow = Boolean(tabs && !isTabsInline);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,29 +75,33 @@ export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderPro
|
||||
below the nav bar while Inventory sat at 24. The gap now comes from one
|
||||
place, the column's own padding, and every page matches.
|
||||
*/}
|
||||
{hasRow ? (
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Left of the row when inline, so the tabs start at the page's left
|
||||
edge and the actions stay on the right. */}
|
||||
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
|
||||
{hasRow || hasTabsRow ? (
|
||||
<StickyRow>
|
||||
{hasRow ? (
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Left of the row when inline, so the tabs start at the page's
|
||||
left edge and the actions stay on the right. */}
|
||||
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
|
||||
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
|
||||
{hasTabsRow ? <div>{tabs}</div> : null}
|
||||
</StickyRow>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
139
src/components/Panel.tsx
Normal file
139
src/components/Panel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import './panel.css';
|
||||
|
||||
/**
|
||||
* The console's card. Every surface that holds a table, a list or a form is
|
||||
* one of these.
|
||||
*
|
||||
* ── Why it has three zones and the old card had one ─────────────────────────
|
||||
*
|
||||
* The card this replaces was a bare rectangle: `<Card padding={0}>` with a
|
||||
* table inside it and nothing else. The title, the row count, the search box
|
||||
* and the tabs all lived ABOVE it, in the page header, so the box itself said
|
||||
* nothing — it drew a line around a table and stopped. No edge treatment fixes
|
||||
* that. A border, a shadow, a gradient: a rectangle with no anatomy reads as
|
||||
* unfinished whichever way you paint its outline, which is exactly what it
|
||||
* looked like.
|
||||
*
|
||||
* So the card frames its content now:
|
||||
*
|
||||
* ┌───────────────────────────────────────┐
|
||||
* │ PRODUCTS 1,248 [Filter] [+ Add] │ header — what this is,
|
||||
* ├───────────────────────────────────────┤ how much of it,
|
||||
* │ …the table… │ what you can do
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ 1–25 of 1,248 ‹ 1 2 3 › │ footer — where you are in it
|
||||
* └───────────────────────────────────────┘
|
||||
*
|
||||
* The header is not decoration. It is the three facts a person needs before
|
||||
* reading a table, in the place the table is, rather than floating above it
|
||||
* where they belong to the page instead of to the data.
|
||||
*
|
||||
* ── The edge ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* A hairline plus a 1px micro-lift — `0 1px 2px` at 4%. Not a blur: a wide
|
||||
* soft shadow makes a panel float, and a page of five floating panels is a
|
||||
* tray of slabs. This is just enough to separate the panel from the canvas
|
||||
* behind it and to make the hairline read as an edge rather than as a drawn
|
||||
* line.
|
||||
*/
|
||||
|
||||
export interface PanelProps {
|
||||
/**
|
||||
* The eyebrow. Set in caps at 11px, because it labels the panel rather than
|
||||
* titling the page — a 20px heading inside a card competes with the page's
|
||||
* own, and the two stacked is what made the old layout repeat itself.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* The row count, beside the title in its own chip.
|
||||
*
|
||||
* Passed as a number and formatted here, so every panel in the console counts
|
||||
* the same way. It is the first thing anybody checks against a filter.
|
||||
*/
|
||||
count?: number | undefined;
|
||||
/** A word for what is counted — "products", "orders". Default: nothing. */
|
||||
countLabel?: string;
|
||||
/** Search, filters, a primary action. Right-aligned in the header band. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* The footer band — in practice always a `<TablePager>`.
|
||||
*
|
||||
* Absent when the pager has nothing to say: `TablePager` returns null on a
|
||||
* single page, and a footer band rendered around null is a 44px strip of
|
||||
* tinted chrome under every short table. Pass it through `footer` and this
|
||||
* decides; do not put a pager in `children`.
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Padding for the body. `none` for a table, which brings its own; `md` for
|
||||
* prose, a form or an empty state.
|
||||
*/
|
||||
padding?: 'none' | 'md';
|
||||
/** Stretches the panel to fill a grid row, for a row of unequal panels. */
|
||||
isFilled?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
count,
|
||||
countLabel,
|
||||
actions,
|
||||
footer,
|
||||
padding = 'none',
|
||||
isFilled,
|
||||
children,
|
||||
}: PanelProps) {
|
||||
const hasHead = Boolean(title || actions || count !== undefined);
|
||||
|
||||
return (
|
||||
<section className="panel-card" {...(isFilled ? { 'data-filled': 'true' } : {})}>
|
||||
{hasHead ? (
|
||||
<header className="panel-card-head">
|
||||
<div className="panel-card-title-group">
|
||||
{title ? <h2 className="panel-card-title">{title}</h2> : null}
|
||||
{/* Rendered at zero too. "0 products" is a fact — the filter
|
||||
matched nothing — and hiding it leaves an empty table with no
|
||||
explanation of whether it is empty or still loading. */}
|
||||
{count === undefined ? null : (
|
||||
<span className="panel-card-count">
|
||||
{count.toLocaleString('en-IN')}
|
||||
{countLabel ? <span className="panel-card-count-label">{countLabel}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{actions ? <div className="panel-card-actions">{actions}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div className="panel-card-body" data-pad={padding}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* `footer` is rendered only when it is truthy, and `TablePager` returns
|
||||
null rather than false — so a single-page table passes an element that
|
||||
renders nothing, and the band would still draw. The check is on the
|
||||
pager's own emptiness instead: see `hasFooter` below. */}
|
||||
{hasFooter(footer) ? <footer className="panel-card-foot">{footer}</footer> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the footer has anything in it.
|
||||
*
|
||||
* `<TablePager>` returns `null` from its own render when there is one page, but
|
||||
* the ELEMENT is still truthy here — React has not called it yet. So a plain
|
||||
* `footer ? … : null` drew an empty tinted band under every table short enough
|
||||
* not to need a pager, which is the one place this design could have looked
|
||||
* worse than the bare box it replaces.
|
||||
*
|
||||
* The band is therefore driven by CSS instead: `:empty` cannot see through the
|
||||
* component boundary, so the footer element is always rendered when a footer is
|
||||
* passed, and `.panel-card-foot:not(:has(*))` collapses it to nothing when the
|
||||
* pager rendered no DOM. `:has` is supported everywhere this console runs.
|
||||
*/
|
||||
function hasFooter(footer: ReactNode): boolean {
|
||||
return footer !== null && footer !== undefined && footer !== false;
|
||||
}
|
||||
70
src/components/SearchInput.tsx
Normal file
70
src/components/SearchInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Search, X } from 'lucide-react';
|
||||
import './searchInput.css';
|
||||
|
||||
/**
|
||||
* The console's search box, shaped to sit beside a `TabBar`.
|
||||
*
|
||||
* Same 32px height, same corner, same hairline and card surface as the track
|
||||
* the view tabs sit in — so a row reading
|
||||
*
|
||||
* [ Orders 85 | Deliveries 1 | Counter sales 312 ] [ 🔍 Search… ]
|
||||
*
|
||||
* reads as one row of controls with a gap in it, rather than as a tab strip
|
||||
* and an unrelated form field that happen to share a line.
|
||||
*
|
||||
* ── Why it is not Astryx's `TextInput` ──────────────────────────────────────
|
||||
*
|
||||
* Every page that has a search wraps `TextInput size="sm"` in a `div` with a
|
||||
* hand-picked width — 230px on Users, 240px on Sales, 260px on Stores — and
|
||||
* `size="sm"` is 32px tall but takes the theme's `--radius-element`, which is
|
||||
* 12px. Against an 8px track that is visibly a different family of corner.
|
||||
* This is the same control at the same height with the track's own radius.
|
||||
*/
|
||||
export interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
/** The accessible name. Say what is being searched — "Search orders". */
|
||||
label: string;
|
||||
/**
|
||||
* Width in px, or `'full'` to fill the container.
|
||||
*
|
||||
* The default suits a page header sitting opposite a tab strip. `'full'` is
|
||||
* for a box that sits directly above the list it filters — a drawer's
|
||||
* district picker, say — where a fixed width over a full-width list reads as
|
||||
* two unrelated things.
|
||||
*/
|
||||
width?: number | 'full';
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, placeholder, label, width = 260 }: SearchInputProps) {
|
||||
return (
|
||||
<div
|
||||
className="searchbox"
|
||||
{...(width === 'full' ? { 'data-full': 'true' } : { style: { width } })}
|
||||
>
|
||||
<Search size={14} className="searchbox-icon" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
className="searchbox-input"
|
||||
value={value}
|
||||
aria-label={label}
|
||||
{...(placeholder ? { placeholder } : {})}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{/* Only when there is something to clear. A permanently visible X on an
|
||||
empty field is a control that does nothing, and it costs the
|
||||
placeholder ten characters of room. */}
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
className="searchbox-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/SelectInput.tsx
Normal file
28
src/components/SelectInput.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
|
||||
export interface SelectInputOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
options: SelectInputOption[];
|
||||
width?: number | 'full';
|
||||
}
|
||||
|
||||
export function SelectInput({ value, onChange, label, options, width = 260 }: SelectInputProps) {
|
||||
return (
|
||||
<div {...(width === 'full' ? { 'data-full': 'true', style: { width: '100%' } } : { style: { width } })}>
|
||||
<Selector
|
||||
label={label}
|
||||
isLabelHidden
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,8 +66,8 @@ export function SheetDropzone({
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: 'var(--card-border)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
}}
|
||||
>
|
||||
@@ -153,7 +153,7 @@ export function SheetDropzone({
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 13,
|
||||
borderRadius: 'var(--card-radius)',
|
||||
border: `1.5px dashed ${
|
||||
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
||||
}`,
|
||||
|
||||
59
src/components/StickyRow.tsx
Normal file
59
src/components/StickyRow.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* A row of page controls held under the nav bar while the page scrolls.
|
||||
*
|
||||
* Whatever sits above a page — its view tabs, its search, its action buttons —
|
||||
* used to leave with the scroll. Reaching the bottom of a long list put every
|
||||
* control over that list off the top of the window, and the only way back to
|
||||
* them was to scroll back through everything just read. The further in somebody
|
||||
* was, the further from the controls.
|
||||
*
|
||||
* The page still scrolls normally. Nothing here gets a scrollbar of its own —
|
||||
* the row simply stays where it is. Positioning and the canvas it paints live
|
||||
* in `.page-sticky`; this component exists for the one part CSS cannot answer,
|
||||
* which is whether the row is currently pinned.
|
||||
*
|
||||
* `PageHeader` wraps itself in this, so every page built from it gets the
|
||||
* behaviour without asking. Dispatch builds its own switcher rather than going
|
||||
* through `PageHeader`, so it wraps itself — which is the reason this is a
|
||||
* component and not four more lines inside the header.
|
||||
*/
|
||||
export function StickyRow({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* Pinned or not, so the hairline can be drawn only when it is.
|
||||
*
|
||||
* At rest the nav bar's own hairline sits just above this one, and two
|
||||
* parallel lines across the page with nothing between them read as a mistake.
|
||||
* The line is what separates the row from content passing UNDER it, so it
|
||||
* belongs to the pinned state rather than to the row.
|
||||
*
|
||||
* Watched with an observer rather than a scroll handler: a scroll listener
|
||||
* runs on every frame of every scroll to answer a question that changes
|
||||
* twice. The negative top margin puts the observer's boundary a pixel above
|
||||
* where the row comes to rest, so "no longer fully inside that box" means
|
||||
* exactly "stuck".
|
||||
*/
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = rowRef.current;
|
||||
/* Guarded for any renderer without it — the row still sticks, it just
|
||||
never grows its line, which is a missing hairline rather than a crash. */
|
||||
if (!element || typeof IntersectionObserver === 'undefined') return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)),
|
||||
{ rootMargin: '-57px 0px 0px 0px', threshold: 1 },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rowRef} className="page-sticky" data-stuck={isStuck ? 'true' : 'false'}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/TabBar.tsx
Normal file
100
src/components/TabBar.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import './tabBar.css';
|
||||
|
||||
/**
|
||||
* The console's view switcher — Orders / Deliveries / Counter sales, Catalogue
|
||||
* / My stock / My requests, Store users / Terminal accounts / Riders.
|
||||
*
|
||||
* ── There were six of these ─────────────────────────────────────────────────
|
||||
*
|
||||
* `TabButton` was defined privately in `StoresPage`, `InventoryPage`,
|
||||
* `ReportsPage`, `SalesPage`, `UsersPage` and `store-user/ui.tsx` — six copies
|
||||
* of the same button, each styled inline, and they had already drifted: three
|
||||
* of them filled the count badge with `--color-warning`, so "85" under Orders
|
||||
* was drawn in the colour this console reserves for something being wrong. A
|
||||
* count is not an alarm. Sales had the sensible version. Nobody could see the
|
||||
* difference because no two of them are ever on screen together.
|
||||
*
|
||||
* ── Why it is a track and not loose pills ───────────────────────────────────
|
||||
*
|
||||
* The old buttons were transparent until selected, so an unselected set read as
|
||||
* four pieces of text floating above the page with no indication they were one
|
||||
* control or that picking one deselects the others. They sit in a recessed
|
||||
* track now, with the selected tab raised out of it on the card surface — the
|
||||
* standard segmented control, and the one shape that says "these are the views,
|
||||
* you are in this one" without needing a label to say so.
|
||||
*/
|
||||
|
||||
export interface TabBarProps {
|
||||
children: ReactNode;
|
||||
/** `sm` for a secondary strip — a filter under a primary switcher. */
|
||||
size?: 'md' | 'sm';
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export function TabBar({ children, size = 'md', ...rest }: TabBarProps) {
|
||||
return (
|
||||
<div className="tabbar" data-size={size} role="tablist" aria-label={rest['aria-label']}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* The count beside the label. Omit it and no count is drawn.
|
||||
*
|
||||
* Whether ZERO is worth drawing depends on what the tab is, so the caller
|
||||
* decides rather than this component:
|
||||
*
|
||||
* - A VIEW passes `x.length || undefined`. "Deliveries 0" beside "Orders
|
||||
* 85" reads as a broken tab; "Deliveries" alone says nothing and lets the
|
||||
* empty state explain.
|
||||
* - A STATUS in a ladder passes the raw count. "Cancelled 0" is the answer
|
||||
* to a question the reader is asking — nothing was cancelled — and a rung
|
||||
* that loses its number when it empties makes the ladder jump about as
|
||||
* the day goes on.
|
||||
*/
|
||||
count?: number | undefined;
|
||||
/**
|
||||
* Draws a hairline before this tab, dividing the track into groups.
|
||||
*
|
||||
* Sales needs it: Orders and Deliveries are two halves of the app's own
|
||||
* trade, and Counter sales is a different channel entirely. The rule says so
|
||||
* without spending a tab on a heading.
|
||||
*/
|
||||
isGroupStart?: boolean;
|
||||
/**
|
||||
* A pulsing dot after the label, for a view that updates by itself.
|
||||
*
|
||||
* Dispatch's Active Fleet is the one: it reads rider positions live, and the
|
||||
* dot is how the board says so. A named prop rather than a general-purpose
|
||||
* slot — a `trailing` that took any node would be the seam this component
|
||||
* exists to close, and there is exactly one live view in the console.
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export function Tab({ label, icon, isActive, onClick, count, isGroupStart, isLive }: TabProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className="tabbar-tab"
|
||||
aria-selected={isActive}
|
||||
{...(isGroupStart ? { 'data-group-start': 'true' } : {})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon ? <span className="tabbar-icon">{icon}</span> : null}
|
||||
<span className="tabbar-label">{label}</span>
|
||||
{count === undefined ? null : <span className="tabbar-count">{count}</span>}
|
||||
{/* Labelled, not decorative: a green dot with no accessible name is a
|
||||
colour that means something to sighted readers only. */}
|
||||
{isLive ? <span className="tabbar-live" role="img" aria-label="updating live" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
436
src/components/TrailMap.tsx
Normal file
436
src/components/TrailMap.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './trailMap.css';
|
||||
|
||||
/**
|
||||
* A map, for things that have a position.
|
||||
*
|
||||
* ── Why leaflet directly and not react-leaflet ──────────────────────────────
|
||||
*
|
||||
* A leaflet map is an imperative object that owns a DOM node and must be torn
|
||||
* down by hand — `remove()`, or the tile layer keeps fetching and the container
|
||||
* keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in
|
||||
* components and adds a second package that has to track React's major version
|
||||
* forever. The wrapping is about forty lines; it is written here instead.
|
||||
*
|
||||
* ── Why the pins are divIcons ───────────────────────────────────────────────
|
||||
*
|
||||
* Leaflet's default marker is a PNG resolved relative to the stylesheet, which
|
||||
* every bundler rewrites and breaks — the classic "markers are invisible"
|
||||
* bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is
|
||||
* markup, so it ships with the bundle, takes the console's brand colour and
|
||||
* needs no image at all.
|
||||
*/
|
||||
|
||||
export interface MapTrail {
|
||||
id: string | number;
|
||||
label: string;
|
||||
points: readonly { lat: number; lng: number }[];
|
||||
colour: string;
|
||||
/**
|
||||
* Drawn thinner and paler, so a focused round reads against the others.
|
||||
*
|
||||
* Muted rather than hidden: a rider's route is still context for the rider
|
||||
* beside them, and hiding it would make two overlapping rounds impossible to
|
||||
* compare — which is the reason to focus one in the first place.
|
||||
*/
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
export interface MapPinPopup {
|
||||
title?: string;
|
||||
orderId?: string;
|
||||
customer?: string;
|
||||
address?: string;
|
||||
status?: string;
|
||||
statusColor?: string;
|
||||
time?: string;
|
||||
rider?: string;
|
||||
amount?: string;
|
||||
step?: number;
|
||||
subtitle?: string;
|
||||
items?: { orderid: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface MapPin {
|
||||
id: string | number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
/** Shown under the label in the popup. Plain text, one line per entry. */
|
||||
lines?: string[];
|
||||
popup?: MapPinPopup;
|
||||
popupHtml?: string;
|
||||
colour?: string;
|
||||
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */
|
||||
isFaded?: boolean;
|
||||
/**
|
||||
* A square marks a place rather than an event — a shop, a depot. Round pins
|
||||
* are the things that happened there, so a branch never reads as one more
|
||||
* customer among its own drops.
|
||||
*/
|
||||
shape?: 'round' | 'square';
|
||||
/**
|
||||
* The stop's place in the round, drawn inside the pin.
|
||||
*
|
||||
* Without it a route is a line through identical dots and there is no way to
|
||||
* read which end it started from — the map says "this is the order it was
|
||||
* worked" and then withholds the order. The console this replaces numbers
|
||||
* every drop for the same reason.
|
||||
*/
|
||||
step?: number;
|
||||
/** Ringed as the round's first stop. */
|
||||
isStart?: boolean;
|
||||
/**
|
||||
* Dim this pin. Used when another round is focused, so the one being read
|
||||
* stands out without the rest disappearing — a rider's stops are still
|
||||
* context for the rider beside them.
|
||||
*/
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
/** Tamil Nadu, so an empty map still shows the right part of the world. */
|
||||
const FALLBACK: L.LatLngExpression = [11.0168, 76.9558];
|
||||
|
||||
export function TrailMap({
|
||||
trails = [],
|
||||
pins = [],
|
||||
height = 380,
|
||||
emptyNote = 'Nothing to place on the map yet.',
|
||||
}: {
|
||||
trails?: readonly MapTrail[];
|
||||
pins?: readonly MapPin[];
|
||||
height?: number;
|
||||
emptyNote?: string;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement | null>(null);
|
||||
const map = useRef<L.Map | null>(null);
|
||||
/* Everything drawn, kept together so a redraw clears exactly what it drew.
|
||||
Clearing the map wholesale would take the tile layer with it. */
|
||||
const drawn = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!host.current || map.current) return;
|
||||
const instance = L.map(host.current, {
|
||||
center: FALLBACK,
|
||||
zoom: 12,
|
||||
// The console scrolls; a wheel over the map should scroll the page, not
|
||||
// zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people
|
||||
// expect from a map embedded in a document.
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: true,
|
||||
});
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(instance);
|
||||
drawn.current = L.layerGroup().addTo(instance);
|
||||
map.current = instance;
|
||||
|
||||
return () => {
|
||||
instance.remove();
|
||||
map.current = null;
|
||||
drawn.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const instance = map.current;
|
||||
const layer = drawn.current;
|
||||
if (!instance || !layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
for (const trail of trails) {
|
||||
if (trail.points.length < 2) continue;
|
||||
const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]);
|
||||
|
||||
/* A white casing under the coloured line.
|
||||
Map tiles are busy and mid-toned, and a 3px stroke of any colour
|
||||
disappears into a main road drawn underneath it. The casing gives the
|
||||
line its own edge so it reads as something laid ON the map rather than
|
||||
part of it. Borrowed from the console this replaces, which draws the
|
||||
same pair — it is the one thing that made its routes legible. */
|
||||
L.polyline(line, {
|
||||
color: '#ffffff',
|
||||
weight: 7,
|
||||
// Muted with its line, or a dimmed route shows a bright white casing
|
||||
// and ends up louder than the one being focused.
|
||||
opacity: trail.isMuted ? 0.2 : 0.65,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
interactive: false,
|
||||
}).addTo(layer);
|
||||
|
||||
L.polyline(line, {
|
||||
color: trail.colour,
|
||||
weight: trail.isMuted ? 2.5 : 3.5,
|
||||
opacity: trail.isMuted ? 0.35 : 0.95,
|
||||
// Rounded joins, or a dense trail draws spikes at every turn.
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
})
|
||||
.bindTooltip(trail.label, { sticky: true })
|
||||
.addTo(layer);
|
||||
|
||||
line.forEach((point) => bounds.extend(point));
|
||||
}
|
||||
|
||||
for (const pin of pins) {
|
||||
const colour = pin.colour ?? 'var(--color-brand)';
|
||||
L.marker([pin.lat, pin.lng], {
|
||||
title: pin.label,
|
||||
icon: L.divIcon({
|
||||
className: 'trail-pin-wrap',
|
||||
// A numbered pin has to be big enough to hold two digits legibly, so
|
||||
// the size follows the content rather than being fixed.
|
||||
html:
|
||||
`<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}"` +
|
||||
` data-shape="${pin.shape ?? 'round'}"` +
|
||||
` data-muted="${pin.isMuted ? 'true' : 'false'}"` +
|
||||
` data-numbered="${pin.step ? 'true' : 'false'}"` +
|
||||
` data-start="${pin.isStart ? 'true' : 'false'}"` +
|
||||
` style="--pin:${escapeAttr(colour)}">` +
|
||||
`${pin.step ? escapeHtml(String(pin.step)) : ''}</span>`,
|
||||
iconSize: pin.step ? [22, 22] : [16, 16],
|
||||
iconAnchor: pin.step ? [11, 11] : [8, 8],
|
||||
}),
|
||||
})
|
||||
.bindPopup(pin.popupHtml ?? formatPinPopup(pin), {
|
||||
className: 'trail-leaflet-popup-card',
|
||||
closeButton: true,
|
||||
maxWidth: 290,
|
||||
minWidth: 230,
|
||||
autoPanPadding: [20, 20],
|
||||
})
|
||||
.addTo(layer);
|
||||
bounds.extend([pin.lat, pin.lng]);
|
||||
}
|
||||
|
||||
if (bounds.isValid()) {
|
||||
// `maxZoom` matters: a single pin, or a rider who never left one street,
|
||||
// otherwise zooms to building level and the map shows one grey rectangle
|
||||
// with no landmarks to orient by.
|
||||
instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 });
|
||||
}
|
||||
}, [trails, pins]);
|
||||
|
||||
/* Leaflet measures its container once, at construction. Inside a drawer or a
|
||||
tab the container is often zero-height at that moment, and the map renders
|
||||
as a grey strip with one tile in the corner until something resizes the
|
||||
window. Re-measuring whenever the box changes size fixes it for good,
|
||||
including when the drawer animates open. */
|
||||
useEffect(() => {
|
||||
const node = host.current;
|
||||
const instance = map.current;
|
||||
if (!node || !instance || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => instance.invalidateSize());
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trail-map" style={{ height }}>
|
||||
<div ref={host} className="trail-map-canvas" />
|
||||
{isEmpty ? <div className="trail-map-empty">{emptyNote}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct, legible line colours.
|
||||
*
|
||||
* Hand-picked rather than generated from a hue wheel: evenly spaced hues put
|
||||
* two yellows next to each other on an OSM tile and both vanish. These are all
|
||||
* dark enough to read over map detail and different enough to tell apart at the
|
||||
* width of a polyline.
|
||||
*/
|
||||
export const TRAIL_COLOURS = [
|
||||
'#662582',
|
||||
'#0f8a5f',
|
||||
'#c2410c',
|
||||
'#1d4ed8',
|
||||
'#b91c1c',
|
||||
'#0e7490',
|
||||
'#7c2d12',
|
||||
'#4d7c0f',
|
||||
] as const;
|
||||
|
||||
export function trailColour(index: number): string {
|
||||
return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string;
|
||||
}
|
||||
|
||||
function formatPinPopup(pin: MapPin): string {
|
||||
const p = pin.popup;
|
||||
if (p) {
|
||||
if (p.items && p.items.length > 1) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">📦</span>
|
||||
<span class="map-popup-id">${p.items.length} Orders Here</span>
|
||||
</div>
|
||||
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar">🏢</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.customer || 'Multiple Drops')}</span>
|
||||
<span class="map-popup-address">${escapeHtml(p.address || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-multi-list">
|
||||
${p.items.slice(0, 4).map(item => `
|
||||
<div class="map-popup-item-row">
|
||||
<span class="map-popup-item-id">#${escapeHtml(item.orderid)}</span>
|
||||
<span class="map-popup-item-status">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${p.items.length > 4 ? `<div class="map-popup-item-more">+${p.items.length - 4} more orders</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-footer">
|
||||
<div class="map-popup-rider">
|
||||
<span class="map-popup-rider-icon">🛵</span>
|
||||
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Assigned')}</span>
|
||||
</div>
|
||||
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (p.orderId) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">📦</span>
|
||||
<span class="map-popup-id">#${escapeHtml(p.orderId)}</span>
|
||||
</div>
|
||||
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar">${escapeHtml((p.customer || 'C').charAt(0).toUpperCase())}</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.customer || 'Customer')}</span>
|
||||
<span class="map-popup-address">${escapeHtml(p.address || 'Delivery Address')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status || 'Pending')}</span>
|
||||
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-footer">
|
||||
<div class="map-popup-rider">
|
||||
<span class="map-popup-rider-icon">🛵</span>
|
||||
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Rider')}</span>
|
||||
</div>
|
||||
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const isStoreHub = p.subtitle?.includes('Hub') || p.title?.includes('Hub') || p.title?.includes('R mart') || pin.shape === 'square';
|
||||
|
||||
if (isStoreHub) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">🏪</span>
|
||||
<span class="map-popup-id">Store Hub</span>
|
||||
</div>
|
||||
<span class="map-popup-step" style="background: #662582;">Origin</span>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar" style="background: rgba(102, 37, 130, 0.12); color: #662582; font-size: 13px;">🏪</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.title || 'R mart')}</span>
|
||||
<span class="map-popup-subtitle" style="font-size: 11px; color: #64748b; font-weight: 500;">${escapeHtml(p.subtitle || 'Fulfillment & Dispatch Hub')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-address-box" style="display: flex; align-items: flex-start; gap: 5px; background: #f8fafc; padding: 6px 8px; border-radius: 6px; border: 1px solid #e2e8f0; font-size: 11px; color: #475569; line-height: 1.35;">
|
||||
<span style="font-size: 12px; flex-shrink: 0; line-height: 1.2;">📍</span>
|
||||
<span>${escapeHtml(p.address || 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002')}</span>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status || 'Active Origin Depot')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">🛵</span>
|
||||
<span class="map-popup-id">${escapeHtml(p.title || pin.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
${p.subtitle ? `<div class="map-popup-customer">${escapeHtml(p.subtitle)}</div>` : ''}
|
||||
${p.address ? `<div class="map-popup-address">${escapeHtml(p.address)}</div>` : ''}
|
||||
${p.status ? `
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status)}</span>
|
||||
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Fallback for default pins
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<span class="map-popup-id">${escapeHtml(pin.label)}</span>
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
${(pin.lines ?? []).map(line => `<div class="map-popup-line">${escapeHtml(line)}</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Popup and icon HTML is a string, so a rider named `<b>` must not become markup. */
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replace(/["'<>]/g, '');
|
||||
}
|
||||
140
src/components/kpiCard.css
Normal file
140
src/components/kpiCard.css
Normal file
@@ -0,0 +1,140 @@
|
||||
/* ══ The KPI tile ══════════════════════════════════════════════════════════
|
||||
The console's headline figure, and the card design the rest of the site
|
||||
follows: the same surface tokens, the same icon tile, the same
|
||||
label / value / note hierarchy.
|
||||
|
||||
Layout is a stack: a top row carrying the label and the icon tile, then the
|
||||
figure at full card width, then the note.
|
||||
|
||||
The figure is LEFT-aligned and owns the whole width, and both halves of that
|
||||
matter. The old tile centred it, so the number sat in a different place in
|
||||
every card depending on how many digits it had and a row of tiles never
|
||||
lined up. The tile before this one put the icon in a column to the left,
|
||||
which left about 134px for the figure at the width these actually get — and
|
||||
"₹1,24,500" wrapped mid-number. */
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
/* A floor, not a height. Labels wrap to two lines at narrow widths — with
|
||||
Nearle Buddy open these are about 195px wide, and "Cancelled orders"
|
||||
ellipsised to "Cance…" is a label that has stopped working — so the tile
|
||||
grows and the row stays level. */
|
||||
min-height: 104px;
|
||||
padding: 16px;
|
||||
|
||||
/* The console's one card surface. Same tokens as `Panel` and every other
|
||||
card in the app; see the note in `index.css`. */
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* ── The icon tile ──────────────────────────────────────────────────────────
|
||||
A rounded square in the tone's tint, one step tighter than the card that
|
||||
holds it — a control radius inside a card radius, which is what keeps it
|
||||
from reading as a small card of its own. */
|
||||
.kpi-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--kpi-tint);
|
||||
color: var(--kpi-ink);
|
||||
}
|
||||
|
||||
/* Brand by default — a tile that is merely reporting should not shout. The
|
||||
other three are for tiles whose figure IS a state. */
|
||||
.kpi-card {
|
||||
--kpi-ink: var(--color-brand);
|
||||
--kpi-tint: var(--color-brand-tint);
|
||||
}
|
||||
.kpi-card[data-tone='success'] {
|
||||
--kpi-ink: var(--color-success, #1f9d55);
|
||||
--kpi-tint: color-mix(in oklab, var(--color-success, #1f9d55) 12%, transparent);
|
||||
}
|
||||
.kpi-card[data-tone='warning'] {
|
||||
--kpi-ink: var(--color-warning, #b7860b);
|
||||
--kpi-tint: color-mix(in oklab, var(--color-warning, #b7860b) 14%, transparent);
|
||||
}
|
||||
.kpi-card[data-tone='error'] {
|
||||
--kpi-ink: var(--color-error, #d64545);
|
||||
--kpi-tint: color-mix(in oklab, var(--color-error, #d64545) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ── The top row ────────────────────────────────────────────────────────── */
|
||||
/* Label left, icon right. The label takes the space the icon does not, and the
|
||||
figure below gets the whole card. */
|
||||
.kpi-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* The label names the figure and stays quiet doing it. Not caps: at 11px in
|
||||
caps with letter-spacing, "Cancelled orders" runs wider than the tile and a
|
||||
two-line eyebrow reads as a heading that broke. Sentence case, ink-3. */
|
||||
.kpi-card-label {
|
||||
font: 500 12.5px/1.3 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
min-width: 0;
|
||||
/* Two lines, then ellipsis — never a third. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The figure. Tabular so a column of tiles lines up on the digits, and pulled
|
||||
in very slightly — at 25px the default tracking makes a rupee figure look
|
||||
spaced out rather than set.
|
||||
|
||||
IT NEVER WRAPS. `overflow-wrap: anywhere` was on this rule and it broke
|
||||
"₹1,24,500" across two lines between the 0 and the 0 — a figure split
|
||||
mid-digit is not a smaller figure, it is a wrong one. A number too wide for
|
||||
its tile ellipsises instead, which is visibly truncated rather than quietly
|
||||
misread, and the `title` is not needed because the same figure is on the
|
||||
page it links to. */
|
||||
.kpi-card-value {
|
||||
font: 700 25px/1.2 var(--font-sans);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-ink-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* What the figure is made of. This is the line that makes the number mean
|
||||
something — see the note in `KpiCard.tsx` for why it had to come back. */
|
||||
.kpi-card-note {
|
||||
margin-top: 3px;
|
||||
font: 400 11.5px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
/* Two lines at most. "142 app · 312 counter" wraps at tile width and that is
|
||||
fine; a third line would push the tile past its neighbours. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* `.kpi-grid` is NOT defined here. It already exists in `index.css`, built on
|
||||
container queries rather than viewport ones — which is what lets a tile row
|
||||
reflow when Nearle Buddy opens beside it and takes 340px out of the column,
|
||||
something a viewport breakpoint cannot see. Defining a second one here would
|
||||
have won on import order and silently thrown that away. */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.kpi-card { min-height: 96px; padding: 14px; }
|
||||
.kpi-card-icon { width: 32px; height: 32px; }
|
||||
.kpi-card-value { font-size: 22px; }
|
||||
}
|
||||
141
src/components/panel.css
Normal file
141
src/components/panel.css
Normal file
@@ -0,0 +1,141 @@
|
||||
/* ══ The panel — the console's one card ════════════════════════════════════
|
||||
Three zones: a header band that says what this is and how much of it there
|
||||
is, a body, and a footer band that says where you are in it. See the note at
|
||||
the top of `Panel.tsx` for why the card grew an anatomy rather than a nicer
|
||||
outline.
|
||||
|
||||
Every size here comes from the card tokens in `index.css`. */
|
||||
|
||||
.panel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
/* The clip belongs on the element that owns the radius, or the header band's
|
||||
tint squares off the top two corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* For a grid row of panels that should finish level with each other. */
|
||||
.panel-card[data-filled='true'] { height: 100%; }
|
||||
|
||||
/* ── Header band ────────────────────────────────────────────────────────── */
|
||||
/* Tinted rather than white, so the band reads as chrome and the rows below it
|
||||
read as data. A white header separated only by a rule is the same surface
|
||||
twice, which is how a heading ends up looking like a first table row. */
|
||||
.panel-card-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 48px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-bottom: var(--card-border);
|
||||
}
|
||||
|
||||
.panel-card-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* An EYEBROW, not a heading. 11px caps: this labels the panel, and a 20px
|
||||
title inside a card competes with the page's own title above it — the two
|
||||
stacked is what made every screen say its subject twice. */
|
||||
.panel-card-title {
|
||||
margin: 0;
|
||||
font: 600 11px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The count, in its own chip. Tabular, because it changes as a filter narrows
|
||||
and a figure that shifts sideways while you read it is hard to trust. */
|
||||
.panel-card-count {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-sunken);
|
||||
font: 600 11.5px/1.45 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.panel-card-count-label {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.panel-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.panel-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel-card-body[data-pad='md'] { padding: 16px; }
|
||||
|
||||
/* ── Footer band ────────────────────────────────────────────────────────── */
|
||||
|
||||
.panel-card-foot {
|
||||
flex: none;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-top: var(--card-border);
|
||||
}
|
||||
|
||||
/* The pager hides itself on a single page — it returns null — but the band
|
||||
around it is rendered by the parent, which cannot see that. Without this a
|
||||
short table carried a 44px strip of tinted chrome saying nothing.
|
||||
`:has` collapses the band when its child rendered no DOM at all. */
|
||||
.panel-card-foot:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The pager's own wrapper adds padding meant for sitting loose under a table.
|
||||
Inside the band the band supplies it. */
|
||||
.panel-card-foot .table-pager {
|
||||
padding: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
/* ── A table sitting flush in the body ──────────────────────────────────────
|
||||
The table brings its own horizontal scroll and its own header row. What it
|
||||
must not bring is a rounded corner or an outer border — the panel owns both,
|
||||
and a table with its own edge inside a panel is the double line this design
|
||||
is meant to remove. */
|
||||
.panel-card-body > .table-scroll {
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Narrow ─────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.panel-card-head { padding: 8px 12px; }
|
||||
.panel-card-foot { padding: 8px 12px; }
|
||||
.panel-card-body[data-pad='md'] { padding: 12px; }
|
||||
/* Actions drop to their own line rather than squeezing the title out. */
|
||||
.panel-card-actions { margin-left: 0; width: 100%; }
|
||||
}
|
||||
82
src/components/searchInput.css
Normal file
82
src/components/searchInput.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* ══ Search box ════════════════════════════════════════════════════════════
|
||||
Shaped to match the view-tab track it sits opposite — see the note in
|
||||
`SearchInput.tsx`. Every number here is the same number `tabBar.css` uses. */
|
||||
|
||||
.searchbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
/* The track's height and the track's corner. This is the whole point of the
|
||||
component: a search that is visibly the same object as the buttons at the
|
||||
other end of the row. */
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--card-radius-sm);
|
||||
border: var(--card-border);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
max-width: 100%;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
/* Fills its container, for a box sitting directly above the list it filters. */
|
||||
.searchbox[data-full='true'] { display: flex; width: 100%; }
|
||||
|
||||
.searchbox:hover { border-color: var(--color-ink-4); }
|
||||
|
||||
/* Focus lands on the BOX, not on the bare input inside it — the input has no
|
||||
border of its own, so the default ring would draw inside the box and read as
|
||||
a second, smaller field. */
|
||||
.searchbox:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.searchbox-icon { flex: none; color: var(--color-ink-4); }
|
||||
.searchbox:focus-within .searchbox-icon { color: var(--color-brand); }
|
||||
|
||||
.searchbox-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--color-ink-1);
|
||||
font: 400 13px/1 var(--font-sans);
|
||||
outline: none;
|
||||
/* Safari draws its own rounded field and inner shadow on a text input, and
|
||||
`type="search"` adds a magnifier and a cancel button on top of ours. */
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.searchbox-input::placeholder { color: var(--color-ink-4); }
|
||||
.searchbox-input::-webkit-search-decoration,
|
||||
.searchbox-input::-webkit-search-cancel-button,
|
||||
.searchbox-input::-webkit-search-results-button {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchbox-clear {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-ink-3);
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, color 140ms ease;
|
||||
}
|
||||
.searchbox-clear:hover { background: var(--color-line); color: var(--color-ink-1); }
|
||||
.searchbox-clear:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
|
||||
|
||||
/* On a phone the header row wraps, and a 260px box beside a scrolling tab
|
||||
track leaves neither enough room. It takes the full line instead. */
|
||||
@media (max-width: 600px) {
|
||||
.searchbox { width: 100% !important; }
|
||||
}
|
||||
@@ -105,6 +105,29 @@ export function AppShell({
|
||||
const isMobile = useIsMobile();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/*
|
||||
A new page starts at the top.
|
||||
|
||||
A single-page app does not reload, so the document keeps whatever scroll
|
||||
offset the last page was left at: read Sales down to row 40, click Inventory,
|
||||
and Inventory opens 2,000px down — usually past everything it has, so it
|
||||
looks empty. Nothing in the app was resetting this.
|
||||
|
||||
INSTANT, deliberately, and it is the one place in the console that opts out
|
||||
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
|
||||
animate those 2,000px on every navigation, which means a second of the new
|
||||
page flying past before it settles — the page arriving late, rather than the
|
||||
page arriving. Smooth is for a move you asked for within a page; a route
|
||||
change is not one.
|
||||
|
||||
Keyed on `pathname` alone, not on the whole location: the branch scope and
|
||||
the date range travel in the query string, and re-scoping a table you are
|
||||
halfway down should leave you where you were.
|
||||
*/
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
}, [pathname]);
|
||||
|
||||
// Escape closes the account menu. A menu that can only be dismissed by
|
||||
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
||||
// sits over the page rather than beside it.
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
approveAssistant,
|
||||
askAssistant,
|
||||
assistantAvailable,
|
||||
type AssistantAnswer,
|
||||
} from '@/api/assistant';
|
||||
import { useAssistantScope } from './assistantScope';
|
||||
import { matchAssistantRoute } from './assistantContext';
|
||||
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
|
||||
import {
|
||||
DEFAULT_WIDTH,
|
||||
@@ -10,114 +18,313 @@ import {
|
||||
useAssistantWidth,
|
||||
} from './assistantWidth';
|
||||
|
||||
/** Per-route context, so the panel knows which page it is sitting beside. */
|
||||
const CONTEXT: Record<
|
||||
string,
|
||||
{ page: string; title: string; greeting: string; reading: string; prompts: string[] }
|
||||
> = {
|
||||
'/nearle/stores': {
|
||||
page: 'Stores',
|
||||
title: 'Nearle Buddy',
|
||||
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
|
||||
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
|
||||
prompts: [
|
||||
'Which tenants have no branches?',
|
||||
'Who onboarded most recently?',
|
||||
'What needs attention?',
|
||||
'Summarise platform growth',
|
||||
],
|
||||
},
|
||||
'/nearle/onboard/tenant': {
|
||||
page: 'Onboard tenant',
|
||||
title: 'Provisioning a tenant',
|
||||
greeting: 'Registering the enterprise also creates its primary Administrator account.',
|
||||
reading: 'Would cover the onboarding form — required fields, category and head-office address.',
|
||||
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
|
||||
},
|
||||
'/nearle/catalogue': {
|
||||
page: 'Global catalogue',
|
||||
title: 'Stocking a store',
|
||||
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
|
||||
reading: 'Would cover the global catalogue and what this tenant has already imported.',
|
||||
prompts: [
|
||||
'Which products are already imported?',
|
||||
'Catalogue or spreadsheet?',
|
||||
'What does re-importing do?',
|
||||
'Which columns does the sheet need?',
|
||||
],
|
||||
},
|
||||
/** One question and what came back. `answer` and `error` are exclusive. */
|
||||
interface Exchange {
|
||||
question: string;
|
||||
answer?: AssistantAnswer;
|
||||
error?: string;
|
||||
/**
|
||||
* What became of a change Buddy proposed.
|
||||
*
|
||||
* `undefined` means the card is still on screen waiting. Once decided the
|
||||
* card is replaced by its outcome and cannot be pressed again — a card that
|
||||
* stayed live after approval is a second write waiting to happen.
|
||||
*/
|
||||
decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string };
|
||||
}
|
||||
|
||||
/* ── Store Admin ──────────────────────────────────────────────────────── */
|
||||
/** Replaces the last entry, which is always the one in flight. */
|
||||
function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] {
|
||||
if (thread.length === 0) return thread;
|
||||
return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry));
|
||||
}
|
||||
|
||||
'/admin/console': {
|
||||
page: 'Console',
|
||||
title: 'Across your branches',
|
||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
||||
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
|
||||
prompts: [
|
||||
'Which branch is underperforming?',
|
||||
'Any tills not syncing?',
|
||||
'What needs my approval?',
|
||||
'Where is stock running out?',
|
||||
],
|
||||
},
|
||||
'/admin/sales': {
|
||||
page: 'Sales',
|
||||
title: 'Orders and deliveries',
|
||||
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
|
||||
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
|
||||
prompts: [
|
||||
'Why is the cancel rate high?',
|
||||
'Which orders are stuck?',
|
||||
'Compare online and counter sales',
|
||||
'What is out for delivery?',
|
||||
],
|
||||
},
|
||||
'/admin/inventory': {
|
||||
page: 'Inventory',
|
||||
title: 'Catalogue and stock',
|
||||
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
|
||||
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
|
||||
prompts: [
|
||||
'What is waiting for approval?',
|
||||
'Which products are unpublished?',
|
||||
'What is low or out of stock?',
|
||||
'How do I upload a product sheet?',
|
||||
],
|
||||
},
|
||||
'/admin/users': {
|
||||
page: 'Users & access',
|
||||
title: 'Who can sign in',
|
||||
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
|
||||
reading: 'Would cover your back-office directory and the till accounts at each branch.',
|
||||
prompts: [
|
||||
'What is the difference between the two?',
|
||||
'How do I add a cashier?',
|
||||
'Why can I not delete someone?',
|
||||
'Who is inactive?',
|
||||
],
|
||||
},
|
||||
'/admin/reports': {
|
||||
page: 'Reports',
|
||||
title: 'Revenue, sales and stock',
|
||||
greeting: 'Fast and slow movers are the two lists that change what you order next.',
|
||||
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
|
||||
prompts: [
|
||||
'Which products are slow moving?',
|
||||
'Compare my branches',
|
||||
'Online versus counter revenue',
|
||||
'What is my inventory worth?',
|
||||
],
|
||||
},
|
||||
};
|
||||
/**
|
||||
* What the composer says when it cannot be used, and why.
|
||||
*
|
||||
* Three different states with three different sentences. "Not connected yet"
|
||||
* for everything would be true and useless: a person whose deployment has no
|
||||
* model can do nothing, but a person on the Inventory page can move to Sales
|
||||
* and get an answer today.
|
||||
*/
|
||||
export function composerHint(
|
||||
available: boolean | null,
|
||||
hasAgent: boolean,
|
||||
isAsking: boolean,
|
||||
): string {
|
||||
if (isAsking) return 'Thinking…';
|
||||
if (available === null) return 'Checking…';
|
||||
if (available === false) return 'Not connected yet';
|
||||
if (!hasAgent) return 'No assistant for this page yet';
|
||||
return 'Ask about this page';
|
||||
}
|
||||
|
||||
const FALLBACK = {
|
||||
page: 'Console',
|
||||
title: 'Good afternoon',
|
||||
greeting: 'Ask about anything on this page.',
|
||||
reading: 'Would cover this page.',
|
||||
prompts: ['What needs attention?', 'Summarise this page'],
|
||||
};
|
||||
/**
|
||||
* The line under the composer, or nothing.
|
||||
*
|
||||
* This used to be one hardcoded sentence — "Not connected yet — there is no
|
||||
* assistant service behind this panel." — written before the panel had an API
|
||||
* behind it and never removed once it did. It rendered on every page in every
|
||||
* state, so the panel told everybody Buddy was off, permanently, including on a
|
||||
* deployment where Buddy was answering questions. It also flatly contradicted
|
||||
* the placeholder three lines above it, which was the only part telling the
|
||||
* truth.
|
||||
*
|
||||
* The lesson worth keeping: a status message with no condition attached is not
|
||||
* a status message. Each branch here reads the same two facts the composer
|
||||
* itself is disabled by, so the two cannot drift apart again.
|
||||
*/
|
||||
export function composerNote(available: boolean | null, hasAgent: boolean): string {
|
||||
// Still asking. A sentence that appears for 200ms and is replaced reads as a
|
||||
// flicker, so the space stays empty until there is something true to put in it.
|
||||
if (available === null) return '';
|
||||
if (available === false) return 'Not connected yet — there is no assistant service behind this panel.';
|
||||
if (!hasAgent) return 'No assistant for this page yet. Try Console or Sales.';
|
||||
// Buddy is working. The caveat that matters then is not about connection: it
|
||||
// is that an answer is a model reading live rows, and the rows are the thing
|
||||
// to act on.
|
||||
return 'Buddy reads your live data. Check anything you act on.';
|
||||
}
|
||||
|
||||
/**
|
||||
* A change waiting on the person.
|
||||
*
|
||||
* Framed and set apart from the reply on purpose. The sentence above it was
|
||||
* written by a model; this was resolved by the server against the database, and
|
||||
* the two must not read as one thing. What is shown here — the ids, the names,
|
||||
* the quantity — is what the button actually agrees to.
|
||||
*
|
||||
* Once decided the buttons are gone, replaced by what happened. A card that
|
||||
* stayed pressable after approval is a second write waiting for a double-click.
|
||||
*/
|
||||
function ApprovalCard({
|
||||
proposal,
|
||||
decision,
|
||||
onDecide,
|
||||
}: {
|
||||
proposal: NonNullable<AssistantAnswer['awaiting']>;
|
||||
decision: Exchange['decision'];
|
||||
onDecide: (approve: boolean) => void;
|
||||
}) {
|
||||
const busy = decision?.state === 'approving';
|
||||
|
||||
const outcomeColour =
|
||||
decision?.state === 'failed'
|
||||
? 'var(--color-error, #d64545)'
|
||||
: decision?.state === 'done'
|
||||
? 'var(--color-success, #1f9d55)'
|
||||
: 'var(--color-ink-3)';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 12,
|
||||
padding: '11px 12px',
|
||||
background: 'var(--color-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: 12.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
|
||||
{proposal.summary}
|
||||
</p>
|
||||
|
||||
{proposal.details?.length ? (
|
||||
<dl style={{ margin: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 10px' }}>
|
||||
{proposal.details.map((detail) => (
|
||||
<Fragment key={detail.label}>
|
||||
<dt style={{ fontSize: 11.5, color: 'var(--color-ink-3)' }}>{detail.label}</dt>
|
||||
<dd style={{ margin: 0, fontSize: 11.5, color: 'var(--color-ink-1)' }}>
|
||||
{detail.value}
|
||||
</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{proposal.warning ? (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--color-warning, #b7860b)',
|
||||
}}
|
||||
>
|
||||
{proposal.warning}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{decision === undefined ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDecide(true)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: 0,
|
||||
background: 'var(--color-brand)',
|
||||
color: '#fff',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
{/* "Not now", not "Reject". Nothing is refused and nothing is
|
||||
recorded — the card is simply left alone, and it expires unused. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDecide(false)}
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-2)',
|
||||
fontSize: 12.5,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: outcomeColour }}>
|
||||
{busy
|
||||
? 'Making the change…'
|
||||
: decision.state === 'dismissed'
|
||||
? 'Left alone. Nothing was changed.'
|
||||
: (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One question and its answer.
|
||||
*
|
||||
* The answer shows what it ran, not just what it concluded. Buddy states things
|
||||
* with the confidence of a sentence, and the only honest way to present that is
|
||||
* beside the tools it used and a link to the page holding the same rows — so a
|
||||
* person can disagree with it.
|
||||
*/
|
||||
function Exchange({
|
||||
entry,
|
||||
isLast,
|
||||
isAsking,
|
||||
onDecide,
|
||||
}: {
|
||||
entry: Exchange;
|
||||
isLast: boolean;
|
||||
isAsking: boolean;
|
||||
onDecide: (approve: boolean) => void;
|
||||
}) {
|
||||
const waiting = isLast && isAsking && !entry.answer && !entry.error;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
alignSelf: 'flex-end',
|
||||
maxWidth: '85%',
|
||||
padding: '7px 11px',
|
||||
borderRadius: '14px 14px 4px 14px',
|
||||
background: 'var(--color-brand-tint, rgba(102,37,130,.08))',
|
||||
color: 'var(--color-ink-1)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{entry.question}
|
||||
</p>
|
||||
|
||||
{waiting ? (
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--color-ink-3)' }}>Looking…</p>
|
||||
) : null}
|
||||
|
||||
{entry.error ? (
|
||||
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-error, #d64545)' }}>
|
||||
{entry.error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entry.answer ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--color-ink-1)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{entry.answer.reply}
|
||||
</p>
|
||||
|
||||
{/* Said plainly, not as a subtle grey hint. A partial answer that
|
||||
looks complete is the failure this flag exists to prevent. */}
|
||||
{entry.answer.incomplete ? (
|
||||
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: 'var(--color-warning, #b7860b)' }}>
|
||||
This answer is partial — Buddy ran out of room before finishing.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entry.answer.used?.length ? (
|
||||
<p style={{ margin: 0, fontSize: 11, lineHeight: 1.5, color: 'var(--color-ink-4)' }}>
|
||||
{entry.answer.used
|
||||
.map((step) =>
|
||||
step.outcome === 'ok'
|
||||
? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}`
|
||||
: `${step.tool} · refused`,
|
||||
)
|
||||
.join(' ')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entry.answer.awaiting ? (
|
||||
<ApprovalCard
|
||||
proposal={entry.answer.awaiting}
|
||||
decision={entry.decision}
|
||||
onDecide={onDecide}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{entry.answer.sources?.length ? (
|
||||
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
|
||||
{entry.answer.sources.map((source) => (
|
||||
<a
|
||||
key={source}
|
||||
href={source}
|
||||
style={{ color: 'var(--color-brand)', textDecoration: 'none', marginRight: 10 }}
|
||||
>
|
||||
See the rows
|
||||
</a>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed against `CONTEXT`'s own shape so the two cannot drift.
|
||||
*
|
||||
* Without this the fallback has no `agent` key at all, and `routeContext` —
|
||||
* which is one or the other — loses the field entirely. A page that fell
|
||||
* through to the fallback would then be a compile error rather than simply a
|
||||
* page with no assistant, which is what it is.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Nearle Buddy, as a layout column.
|
||||
@@ -143,11 +350,11 @@ export function AssistantPanel({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const threadRef = useRef<HTMLDivElement>(null);
|
||||
const { width, setWidth, reset } = useAssistantWidth();
|
||||
const [isDragging, setDragging] = useState(false);
|
||||
|
||||
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
|
||||
const routeContext = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
|
||||
const { key, context: routeContext } = matchAssistantRoute(pathname);
|
||||
|
||||
/**
|
||||
* The heading follows the branch in view.
|
||||
@@ -159,6 +366,136 @@ export function AssistantPanel({
|
||||
const scopeLabel = useAssistantScope();
|
||||
const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext;
|
||||
|
||||
/*
|
||||
* Whether Buddy can answer here at all, and why not when it cannot.
|
||||
*
|
||||
* Two separate reasons, kept apart because they are two different facts and
|
||||
* the person can act on one of them. `available === false` means this
|
||||
* deployment has no model configured — nothing to be done from the browser.
|
||||
* `agent === undefined` means this page has no assistant yet, which is about
|
||||
* the page and not the deployment.
|
||||
*
|
||||
* `null` is "not asked yet": the composer stays disabled during the check, so
|
||||
* it is never briefly live against a server that turns out to have no model.
|
||||
*/
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
const [thread, setThread] = useState<Exchange[]>([]);
|
||||
const [isAsking, setAsking] = useState(false);
|
||||
const agent = routeContext.agent;
|
||||
const canAsk = available === true && Boolean(agent) && !isAsking;
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void assistantAvailable().then((ok) => {
|
||||
if (live) setAvailable(ok);
|
||||
});
|
||||
// Cancelled on unmount so a slow answer cannot set state on a closed panel.
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The thread is per-page and deliberately not persisted.
|
||||
*
|
||||
* An answer about Sales sitting above the Inventory page is worse than no
|
||||
* answer: it reads as being about what is on screen. Clearing on navigation
|
||||
* costs a person their history, which is the smaller loss.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setThread([]);
|
||||
}, [key]);
|
||||
|
||||
/*
|
||||
* Follow the conversation down, unless the person has scrolled away from it.
|
||||
*
|
||||
* A chat that does not follow leaves the newest answer below the fold, which
|
||||
* reads as nothing having happened. One that follows unconditionally yanks
|
||||
* somebody out of an earlier answer they were still reading the moment a
|
||||
* reply lands — and a reply can land a while after the question, because a
|
||||
* tool call and a model round trip are seconds, not milliseconds.
|
||||
*
|
||||
* So: only when they were already at the bottom. The 40px allowance covers
|
||||
* fractional scroll heights, which browsers disagree about by a pixel or two
|
||||
* at non-integer zoom levels — without it this silently stops following for
|
||||
* anybody not at 100%.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const el = threadRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > 40) return;
|
||||
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
}, [thread]);
|
||||
|
||||
/*
|
||||
* Approving is its own call, with no question in it.
|
||||
*
|
||||
* Indexed rather than acting on the last exchange: a person can scroll up and
|
||||
* approve an earlier card, and nothing about a card ties it to being the most
|
||||
* recent thing said.
|
||||
*/
|
||||
const decide = async (index: number, approve: boolean) => {
|
||||
const entry = thread[index];
|
||||
const card = entry?.answer?.awaiting?.card;
|
||||
if (!card || !agent || entry.decision) return;
|
||||
|
||||
if (!approve) {
|
||||
// Dismissing is purely local: the server was never told, because there is
|
||||
// nothing to undo. The card simply expires unused.
|
||||
setThread((current) =>
|
||||
current.map((e, i) => (i === index ? { ...e, decision: { state: 'dismissed' as const } } : e)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setThread((current) =>
|
||||
current.map((e, i) => (i === index ? { ...e, decision: { state: 'approving' as const } } : e)),
|
||||
);
|
||||
|
||||
try {
|
||||
const done = await approveAssistant(agent, card);
|
||||
setThread((current) =>
|
||||
current.map((e, i) =>
|
||||
i === index ? { ...e, decision: { state: 'done' as const, message: done.reply } } : e,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
// "Somebody already approved that" arrives here, and it is an answer
|
||||
// rather than a fault — shown on the card, which stays decided so the
|
||||
// button cannot be pressed again into the same refusal.
|
||||
setThread((current) =>
|
||||
current.map((e, i) =>
|
||||
i === index ? { ...e, decision: { state: 'failed' as const, message: errorMessage(error) } } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || !agent || !canAsk) return;
|
||||
|
||||
setDraft('');
|
||||
setAsking(true);
|
||||
setThread((current) => [...current, { question }]);
|
||||
|
||||
try {
|
||||
const answer = await askAssistant(agent, question);
|
||||
setThread((current) => replaceLast(current, (entry) => ({ ...entry, answer })));
|
||||
} catch (error) {
|
||||
// Shown in the thread rather than as a toast: the question is still on
|
||||
// screen, and the failure belongs next to it.
|
||||
setThread((current) =>
|
||||
replaceLast(current, (entry) => ({ ...entry, error: errorMessage(error) })),
|
||||
);
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Three widths, in priority order: stacked (the phone layout owns it),
|
||||
* expanded (the one-click half-screen), then whatever the operator dragged
|
||||
@@ -257,6 +594,35 @@ export function AssistantPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The scrolling part, and the only one.
|
||||
|
||||
Everything above this is chrome that stays put; everything below it is
|
||||
the composer, which must never leave the screen. Before this existed
|
||||
the panel was one `height: 100%` column with no overflow anywhere: a
|
||||
thread longer than the panel pushed the chips and the composer past
|
||||
the bottom edge, and there was nothing to scroll — the box you type
|
||||
into simply left the screen and the conversation could not be read
|
||||
back.
|
||||
|
||||
`minHeight: 0` is the load-bearing half. A flex child defaults to
|
||||
`min-height: auto`, which means it refuses to shrink below its content,
|
||||
so `flex: 1` alone would let this grow to the height of the whole
|
||||
conversation and overflow the panel exactly as before — the scrollbar
|
||||
never appears and nothing looks wrong in the code.
|
||||
*/}
|
||||
<div
|
||||
ref={threadRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Greeting — top-aligned, because it is the first thing in a
|
||||
conversation, not a splash screen. */}
|
||||
<div>
|
||||
@@ -296,25 +662,58 @@ export function AssistantPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
{/* The conversation. Inside the scroller with the greeting above it, so
|
||||
a short thread reads straight on from the greeting and a long one
|
||||
scrolls under it — with the chips and the composer staying put. */}
|
||||
{thread.length > 0 ? (
|
||||
<div
|
||||
role="log"
|
||||
aria-label="Conversation with Nearle Buddy"
|
||||
aria-busy={isAsking}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
|
||||
>
|
||||
{thread.map((entry, index) => (
|
||||
<Exchange
|
||||
key={index}
|
||||
entry={entry}
|
||||
isLast={index === thread.length - 1}
|
||||
isAsking={isAsking}
|
||||
onDecide={(approve) => void decide(index, approve)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
|
||||
is a bug no amount of fade masking fixes. */}
|
||||
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{context.prompts.map((prompt) => (
|
||||
<Chip key={prompt} label={prompt} onSelect={() => setDraft(prompt)} />
|
||||
<Chip
|
||||
key={prompt}
|
||||
label={prompt}
|
||||
/* A chip sends when Buddy can answer, and only fills the box when it
|
||||
cannot — so a chip on a page with no assistant still shows what
|
||||
could be asked rather than doing nothing on click. */
|
||||
onSelect={() => (canAsk ? void send(prompt) : setDraft(prompt))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Disabled, not merely inert.
|
||||
|
||||
{/* Live, at last — but only when something can actually answer.
|
||||
|
||||
This composer used to accept text and enable a brand-purple send
|
||||
button the moment you typed — then swallow the submit. There is no
|
||||
assistant endpoint anywhere in `src/api`. A disabled field says the
|
||||
same thing honestly and costs nobody a message they thought they
|
||||
sent. */}
|
||||
button the moment you typed, then swallow the submit, because no
|
||||
assistant endpoint existed. It was disabled rather than left to lie.
|
||||
It is enabled here exactly when a model is configured AND this page
|
||||
has an agent, and the placeholder says which of the two is missing
|
||||
otherwise. The rule it keeps is the old one: never accept a message
|
||||
nothing will read. */}
|
||||
<form
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void send(draft);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
@@ -341,9 +740,18 @@ export function AssistantPanel({
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled
|
||||
placeholder="Not connected yet"
|
||||
aria-label="Ask Nearle Buddy — not connected yet"
|
||||
onKeyDown={(event) => {
|
||||
// Enter sends, shift+Enter writes a second line. The composer is
|
||||
// one line tall most of the time, so requiring the button would
|
||||
// make every question a two-action job.
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send(draft);
|
||||
}
|
||||
}}
|
||||
disabled={!canAsk}
|
||||
placeholder={composerHint(available, Boolean(agent), isAsking)}
|
||||
aria-label={`Ask Nearle Buddy — ${composerHint(available, Boolean(agent), isAsking)}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
@@ -362,18 +770,19 @@ export function AssistantPanel({
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Send message"
|
||||
disabled
|
||||
disabled={!canAsk || draft.trim() === ''}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
border: 0,
|
||||
flex: 'none',
|
||||
background: 'var(--color-surface-sunken)',
|
||||
color: 'var(--color-ink-4)',
|
||||
background:
|
||||
canAsk && draft.trim() !== '' ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||
color: canAsk && draft.trim() !== '' ? '#fff' : 'var(--color-ink-4)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
cursor: 'default',
|
||||
cursor: canAsk && draft.trim() !== '' ? 'pointer' : 'default',
|
||||
transition: 'background .2s',
|
||||
}}
|
||||
>
|
||||
@@ -381,17 +790,19 @@ export function AssistantPanel({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
color: 'var(--color-ink-4)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Not connected yet — there is no assistant service behind this panel.
|
||||
</p>
|
||||
{composerNote(available, Boolean(agent)) ? (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.4,
|
||||
color: 'var(--color-ink-4)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{composerNote(available, Boolean(agent))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -28,39 +28,85 @@ import {
|
||||
|
||||
export interface DateScopeValue {
|
||||
preset: RangePreset;
|
||||
/**
|
||||
* What the PAGES read — always a real window.
|
||||
*
|
||||
* When nobody has picked anything this is the default week, not an empty
|
||||
* object. Left empty, every read on the console became unbounded and each
|
||||
* part of a page bounded it differently: the Console's chart drew however
|
||||
* many days the last 500 orders happened to span (nine, in practice) while
|
||||
* the KPI tiles beside it totalled all of them, and the counter figures came
|
||||
* from a POS summary with no window at all. One page, three answers.
|
||||
*/
|
||||
range: DateRange;
|
||||
/**
|
||||
* What the PICKER shows — empty until somebody chooses.
|
||||
*
|
||||
* This is the half that keeps the filter unselected on arrival. The two are
|
||||
* separate on purpose: "no filter has been set" is a fact about the control,
|
||||
* and "which week are we looking at" is a fact about the data, and collapsing
|
||||
* them into one value is what forced a choice between a filter nobody set and
|
||||
* a page with no window.
|
||||
*/
|
||||
chosen: DateRange;
|
||||
set: (preset: RangePreset, range: DateRange) => void;
|
||||
/** Back to no filter at all — what an empty state offers as a way out. */
|
||||
clear: () => void;
|
||||
/** True when a range is actually narrowing the page. */
|
||||
/** True when the USER has narrowed the page, not merely that a window exists. */
|
||||
isFiltered: boolean;
|
||||
}
|
||||
|
||||
/** Where the console starts. Month to date is what most pages want. */
|
||||
export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
|
||||
/**
|
||||
* The window the console shows when nobody has picked one.
|
||||
*
|
||||
* A WEEK — while the picker still shows nothing selected. Those are two
|
||||
* separate statements and both are deliberate.
|
||||
*
|
||||
* The filter opens unselected because the console used to narrow every page
|
||||
* before anyone asked: somebody signing in to see how trade is going was shown
|
||||
* a slice with no sign that it was one, and the range then followed them across
|
||||
* the whole console.
|
||||
*
|
||||
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
|
||||
* capped only by `pagesize`, and each part of a page then bounded itself
|
||||
* differently — the Console's chart drew however many days the last 500 orders
|
||||
* happened to span (nine), the KPI tiles beside it totalled all of those
|
||||
* orders, and the counter figures came from a POS summary with no window
|
||||
* whatsoever. One page, three different answers to "when".
|
||||
*
|
||||
* A default window is not a filter. Nothing is hidden from the reader, the
|
||||
* control is empty, and one choice replaces it.
|
||||
*/
|
||||
const DEFAULT_WINDOW: RangePreset = 'week';
|
||||
|
||||
const DateScopeContext = createContext<DateScopeValue | null>(null);
|
||||
|
||||
export function DateScopeProvider({ children }: { children: ReactNode }) {
|
||||
const [preset, setPreset] = useState<RangePreset>(DEFAULT_RANGE_PRESET);
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange(DEFAULT_RANGE_PRESET));
|
||||
/* What the user picked. Empty until they do — this is what the picker shows. */
|
||||
const [preset, setPreset] = useState<RangePreset>('custom');
|
||||
const [chosen, setChosen] = useState<DateRange>({});
|
||||
|
||||
const value = useMemo<DateScopeValue>(
|
||||
() => ({
|
||||
const value = useMemo<DateScopeValue>(() => {
|
||||
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
|
||||
return {
|
||||
preset,
|
||||
range,
|
||||
chosen,
|
||||
/* The window the pages actually read: what was picked, or the default
|
||||
week. Resolved here, once, so every read on a page shares it — the
|
||||
orders, the POS summaries and the chart cannot end up describing
|
||||
different spans. */
|
||||
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
|
||||
set: (nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
setChosen(nextRange);
|
||||
},
|
||||
clear: () => {
|
||||
setPreset('custom');
|
||||
setRange({});
|
||||
setChosen({});
|
||||
},
|
||||
isFiltered: Boolean(range.fromdate || range.todate),
|
||||
}),
|
||||
[preset, range],
|
||||
);
|
||||
isFiltered: hasChoice,
|
||||
};
|
||||
}, [preset, chosen]);
|
||||
|
||||
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
|
||||
}
|
||||
@@ -81,5 +127,5 @@ export function useDateScope(): DateScopeValue {
|
||||
/** The control itself. Rendered once, in the top bar. */
|
||||
export function DateScopePicker() {
|
||||
const dates = useDateScope();
|
||||
return <DateRangePicker preset={dates.preset} range={dates.range} onChange={dates.set} />;
|
||||
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
|
||||
}
|
||||
|
||||
91
src/components/shell/assistantContext.test.ts
Normal file
91
src/components/shell/assistantContext.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { CONTEXT, matchAssistantRoute } from './assistantContext';
|
||||
|
||||
/*
|
||||
The bug these exist for: the console has THREE workspaces — `/nearle` for
|
||||
platform staff, `/admin` for a merchant, `/store` for a branch user — and this
|
||||
map only knew about two. A store user therefore got the fallback on every page,
|
||||
and the fallback carries no agent, so the composer was dead for that entire role
|
||||
however the deployment was configured.
|
||||
|
||||
Nothing caught it because `/store/console` and `/admin/console` render the same
|
||||
component and look identical on screen, down to the heading. The only difference
|
||||
is the pathname, which is precisely the input this map takes.
|
||||
*/
|
||||
|
||||
/** The routes that must be able to answer, per workspace. */
|
||||
const MUST_HAVE_AN_AGENT = [
|
||||
'/admin/console',
|
||||
'/admin/sales',
|
||||
'/admin/inventory',
|
||||
'/store/console',
|
||||
'/store/sales',
|
||||
];
|
||||
|
||||
test('every workspace that can answer, does', () => {
|
||||
for (const pathname of MUST_HAVE_AN_AGENT) {
|
||||
const { context } = matchAssistantRoute(pathname);
|
||||
assert.ok(
|
||||
context.agent,
|
||||
`${pathname} has no agent, so its composer is disabled whatever the backend says`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the two console pages answer with the same agent', () => {
|
||||
// `/admin/console` and `/store/console` are the same page at two scopes. One
|
||||
// of them carrying an agent and the other not is the exact shape of the bug,
|
||||
// and it is invisible in the rendered output.
|
||||
const merchant = matchAssistantRoute('/admin/console').context;
|
||||
const branch = matchAssistantRoute('/store/console').context;
|
||||
|
||||
assert.equal(branch.agent, merchant.agent);
|
||||
assert.equal(matchAssistantRoute('/store/sales').context.agent, 'orders');
|
||||
});
|
||||
|
||||
test('a page falls through to the fallback rather than to nothing', () => {
|
||||
const { key, context } = matchAssistantRoute('/somewhere/unmapped');
|
||||
|
||||
assert.equal(key, undefined);
|
||||
assert.equal(context.agent, undefined);
|
||||
// The fallback must stay agentless. Giving it one would point every unmapped
|
||||
// page at an assistant that cannot answer what is on it.
|
||||
assert.ok(context.prompts.length > 0);
|
||||
});
|
||||
|
||||
test('a deeper path still finds its page', () => {
|
||||
// `/admin/sales/4412` is still the Sales panel. Exact matching would drop the
|
||||
// agent the moment anybody opened a row.
|
||||
assert.equal(matchAssistantRoute('/admin/sales/4412').context.agent, 'orders');
|
||||
assert.equal(matchAssistantRoute('/store/console?branch=3').context.agent, 'console');
|
||||
});
|
||||
|
||||
test('no key shadows another', () => {
|
||||
// First match wins, in insertion order, so a key that is a prefix of another
|
||||
// silently swallows it — adding a bare `/store` above `/store/console` would
|
||||
// take the agent away from the console page and nothing would fail.
|
||||
const keys = Object.keys(CONTEXT);
|
||||
|
||||
for (const key of keys) {
|
||||
for (const other of keys) {
|
||||
if (key === other) continue;
|
||||
assert.ok(
|
||||
!other.startsWith(key),
|
||||
`${key} is a prefix of ${other}; whichever is declared first wins and the other is unreachable`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every agent named here exists on the backend', () => {
|
||||
// The agents are YAML in backend_fiesta/services/agents. A name that does not
|
||||
// match one is refused at the door, and the person sees a failed question
|
||||
// rather than a disabled composer — worse, because it looks like a fault.
|
||||
const shipped = new Set(['console', 'orders', 'inventory', 'shopfloor', 'platform']);
|
||||
|
||||
for (const [route, context] of Object.entries(CONTEXT)) {
|
||||
if (!context.agent) continue;
|
||||
assert.ok(shipped.has(context.agent), `${route} names an agent that does not exist: ${context.agent}`);
|
||||
}
|
||||
});
|
||||
224
src/components/shell/assistantContext.ts
Normal file
224
src/components/shell/assistantContext.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Which page Nearle Buddy is sitting beside, and which agent answers there.
|
||||
*
|
||||
* Its own module so it can be tested without rendering the panel. It was
|
||||
* inline in AssistantPanel.tsx, and a whole workspace went missing from it
|
||||
* unnoticed — see the store-user block below.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Per-route context, so the panel knows which page it is sitting beside.
|
||||
*
|
||||
* `agent` names which assistant answers here, and its absence is meaningful: a
|
||||
* route without one has no assistant yet, and the composer says so rather than
|
||||
* accepting a question nothing can answer.
|
||||
*
|
||||
* A page is only given an agent when that agent can answer every chip on it. A
|
||||
* chip that comes back "I cannot look that up" is worse than no chip — it reads
|
||||
* as the assistant being broken rather than as a feature not built yet. Reports
|
||||
* has no agent for exactly that reason: two of its four questions have no tool
|
||||
* behind them.
|
||||
*
|
||||
* The agents themselves are YAML on the backend, so a new one is a file there
|
||||
* and one line here.
|
||||
*/
|
||||
export const CONTEXT: Record<
|
||||
string,
|
||||
{
|
||||
page: string;
|
||||
title: string;
|
||||
greeting: string;
|
||||
reading: string;
|
||||
prompts: string[];
|
||||
agent?: string;
|
||||
}
|
||||
> = {
|
||||
/* The platform workspace was here — three routes pointing at a `platform`
|
||||
agent. It moved out with the rest of Nearle Admin: those paths are not
|
||||
routes in this application any more, so the entries could never match and
|
||||
only shipped platform copy to merchants. They live in `nearle-platform`. */
|
||||
|
||||
/* ── Store Admin ──────────────────────────────────────────────────────── */
|
||||
|
||||
'/admin/console': {
|
||||
agent: 'console',
|
||||
page: 'Console',
|
||||
title: 'Across your branches',
|
||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
||||
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
|
||||
prompts: [
|
||||
'Which branch is underperforming?',
|
||||
'Any tills not syncing?',
|
||||
'What needs my approval?',
|
||||
'Where is stock running out?',
|
||||
],
|
||||
},
|
||||
'/admin/sales': {
|
||||
agent: 'orders',
|
||||
page: 'Sales',
|
||||
title: 'Orders and deliveries',
|
||||
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
|
||||
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
|
||||
prompts: [
|
||||
'Why is the cancel rate high?',
|
||||
'Which orders are stuck?',
|
||||
'Compare online and counter sales',
|
||||
'What is out for delivery?',
|
||||
],
|
||||
},
|
||||
'/admin/inventory': {
|
||||
agent: 'inventory',
|
||||
page: 'Inventory',
|
||||
title: 'Catalogue and stock',
|
||||
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
|
||||
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
|
||||
prompts: [
|
||||
'What is waiting for approval?',
|
||||
'What is low or out of stock?',
|
||||
'What has run out completely?',
|
||||
'Which branch is waiting on most stock?',
|
||||
],
|
||||
},
|
||||
'/admin/users': {
|
||||
page: 'Users & access',
|
||||
title: 'Who can sign in',
|
||||
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
|
||||
reading: 'Would cover your back-office directory and the till accounts at each branch.',
|
||||
prompts: [
|
||||
'What is the difference between the two?',
|
||||
'How do I add a cashier?',
|
||||
'Why can I not delete someone?',
|
||||
'Who is inactive?',
|
||||
],
|
||||
},
|
||||
'/admin/reports': {
|
||||
page: 'Reports',
|
||||
title: 'Revenue, sales and stock',
|
||||
greeting: 'Fast and slow movers are the two lists that change what you order next.',
|
||||
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
|
||||
prompts: [
|
||||
'Which products are slow moving?',
|
||||
'Compare my branches',
|
||||
'Online versus counter revenue',
|
||||
'What is my inventory worth?',
|
||||
],
|
||||
},
|
||||
|
||||
/* ── Store user ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* One branch, fixed. The pages are largely the SAME components the merchant
|
||||
* sees — `/store/console` and `/admin/console` are both `ConsolePage` — so
|
||||
* the absence of these entries was invisible in the code and total in the
|
||||
* product: a store-manager got the fallback on every route, and the fallback
|
||||
* has no agent, so the composer was dead for that whole role no matter what
|
||||
* the deployment had configured.
|
||||
*
|
||||
* It stayed hidden because every test signed in as one of the other two
|
||||
* roles, and because `/admin/console` and `/store/console` look identical on
|
||||
* screen down to the heading.
|
||||
*
|
||||
* The wording differs from the merchant's on purpose. "Which branch is
|
||||
* underperforming?" is not a question a branch user can act on, and the
|
||||
* answers are scoped to their one branch by the session regardless — so the
|
||||
* chips say what that scope actually is rather than implying a choice.
|
||||
*/
|
||||
|
||||
'/store/console': {
|
||||
agent: 'console',
|
||||
page: 'Console',
|
||||
title: 'Your branch today',
|
||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
||||
reading: 'Would cover this branch — sales by channel, stock health, till status and what is waiting.',
|
||||
prompts: [
|
||||
'Any tills not syncing?',
|
||||
'Where is stock running out?',
|
||||
'What needs attention?',
|
||||
'How is today going?',
|
||||
],
|
||||
},
|
||||
'/store/sales': {
|
||||
agent: 'orders',
|
||||
page: 'Sales',
|
||||
title: 'Orders and deliveries',
|
||||
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
|
||||
reading: 'Would cover this branch — orders, counter bills and delivery progress.',
|
||||
prompts: [
|
||||
'Which orders are stuck?',
|
||||
'What is out for delivery?',
|
||||
'Compare online and counter sales',
|
||||
'Why is the cancel rate high?',
|
||||
],
|
||||
},
|
||||
'/store/products': {
|
||||
// No agent, and not an oversight. The `inventory` agent carries
|
||||
// `approve_stock_request`, and whether a branch user may approve the stock
|
||||
// their own branch asked for is a question about who is allowed to spend,
|
||||
// not about which page this is. Until that is settled the page keeps the
|
||||
// prompts and loses the composer, which is the honest of the two.
|
||||
page: 'Products',
|
||||
title: 'Catalogue and stock',
|
||||
greeting: 'A product in the catalogue does not mean stock on the shelf — that takes an approved request.',
|
||||
reading: 'Would cover this branch — what is on the shelf and what has been asked for.',
|
||||
prompts: [
|
||||
'What is low or out of stock?',
|
||||
'What has run out completely?',
|
||||
'How do I request stock?',
|
||||
'Why is my request still pending?',
|
||||
],
|
||||
},
|
||||
'/store/reports': {
|
||||
page: 'Reports',
|
||||
title: 'Revenue, sales and stock',
|
||||
greeting: 'Fast and slow movers are the two lists that change what you order next.',
|
||||
reading: 'Would cover this branch — revenue by channel, product performance and stock movement.',
|
||||
prompts: [
|
||||
'Which products are slow moving?',
|
||||
'Online versus counter revenue',
|
||||
'What sold best this week?',
|
||||
'What is my stock worth?',
|
||||
],
|
||||
},
|
||||
'/store/staff': {
|
||||
page: 'Staff',
|
||||
title: 'Who can sign in',
|
||||
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
|
||||
reading: 'Would cover the people at this branch and the till accounts they sign in with.',
|
||||
prompts: [
|
||||
'What is the difference between the two?',
|
||||
'How do I add a cashier?',
|
||||
'Why can I not delete someone?',
|
||||
'Who is inactive?',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK: (typeof CONTEXT)[string] = {
|
||||
page: 'Console',
|
||||
title: 'Good afternoon',
|
||||
greeting: 'Ask about anything on this page.',
|
||||
reading: 'Would cover this page.',
|
||||
prompts: ['What needs attention?', 'Summarise this page'],
|
||||
};
|
||||
|
||||
/** One route's panel copy. */
|
||||
export type AssistantRouteContext = (typeof CONTEXT)[string];
|
||||
|
||||
/**
|
||||
* Which entry a pathname lands on, and its key.
|
||||
*
|
||||
* Prefix matching, so `/admin/sales/4412` is still the Sales panel. The key is
|
||||
* returned alongside because the panel clears its thread when it changes — two
|
||||
* different pages must not share a conversation.
|
||||
*
|
||||
* First match wins, in insertion order, which is safe only while no key is a
|
||||
* prefix of another. That is asserted in the tests rather than left as a
|
||||
* property somebody has to notice.
|
||||
*/
|
||||
export function matchAssistantRoute(pathname: string): {
|
||||
key: string | undefined;
|
||||
context: AssistantRouteContext;
|
||||
} {
|
||||
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
|
||||
return { key, context: (key ? CONTEXT[key] : undefined) ?? FALLBACK };
|
||||
}
|
||||
70
src/components/shell/composerState.test.ts
Normal file
70
src/components/shell/composerState.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { composerHint, composerNote } from './AssistantPanel';
|
||||
|
||||
/*
|
||||
The panel carried a hardcoded sentence under the composer — "Not connected yet
|
||||
— there is no assistant service behind this panel." — written before there was
|
||||
an API behind it and never removed once there was. It had no condition on it, so
|
||||
it rendered in every state on every page, and it said Buddy was off while the
|
||||
placeholder three lines above said "Ask about this page".
|
||||
|
||||
That cost real debugging time: the panel was read as evidence the backend had no
|
||||
model, on a deployment whose backend was answering questions.
|
||||
|
||||
So both lines are asserted together here. The rule is that they agree.
|
||||
*/
|
||||
|
||||
const CHECKING = null;
|
||||
const LIVE = true;
|
||||
const OFF = false;
|
||||
|
||||
test('the note and the placeholder never disagree', () => {
|
||||
// Whenever the note claims Buddy is not connected, the placeholder must say
|
||||
// the same, and vice versa. Disagreeing is the bug, in either direction.
|
||||
for (const available of [CHECKING, LIVE, OFF]) {
|
||||
for (const hasAgent of [true, false]) {
|
||||
const note = composerNote(available, hasAgent);
|
||||
const hint = composerHint(available, hasAgent, false);
|
||||
|
||||
assert.equal(
|
||||
note.startsWith('Not connected yet'),
|
||||
hint === 'Not connected yet',
|
||||
`note ${JSON.stringify(note)} disagrees with placeholder ${JSON.stringify(hint)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a working assistant does not announce that it is disconnected', () => {
|
||||
// The regression itself.
|
||||
const note = composerNote(LIVE, true);
|
||||
|
||||
assert.ok(!note.includes('Not connected'), `a live panel still said: ${note}`);
|
||||
assert.equal(composerHint(LIVE, true, false), 'Ask about this page');
|
||||
});
|
||||
|
||||
test('an unconfigured deployment says so', () => {
|
||||
assert.match(composerNote(OFF, true), /Not connected yet/);
|
||||
assert.match(composerNote(OFF, false), /Not connected yet/);
|
||||
});
|
||||
|
||||
test('a page with no agent says it is the page, not the deployment', () => {
|
||||
// Two different facts, and only one of them is something the person can do
|
||||
// anything about: they can move to a page that has an assistant.
|
||||
const note = composerNote(LIVE, false);
|
||||
|
||||
assert.ok(!note.includes('Not connected'));
|
||||
assert.match(note, /no assistant for this page/i);
|
||||
});
|
||||
|
||||
test('nothing is claimed while the check is still in flight', () => {
|
||||
// A sentence that appears and is replaced 200ms later reads as a flicker, and
|
||||
// the one it used to show was the wrong one.
|
||||
assert.equal(composerNote(CHECKING, true), '');
|
||||
assert.equal(composerHint(CHECKING, true, false), 'Checking…');
|
||||
});
|
||||
|
||||
test('asking outranks everything in the placeholder', () => {
|
||||
assert.equal(composerHint(LIVE, true, true), 'Thinking…');
|
||||
});
|
||||
165
src/components/tabBar.css
Normal file
165
src/components/tabBar.css
Normal file
@@ -0,0 +1,165 @@
|
||||
/* ══ The view switcher ═════════════════════════════════════════════════════
|
||||
A segmented control: a recessed track, and the selected view raised out of
|
||||
it onto the card surface. See the note in `TabBar.tsx` for why this replaced
|
||||
six private copies of a transparent pill. */
|
||||
|
||||
.tabbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--color-surface-sunken);
|
||||
/* The track is a control, so it takes the control radius rather than the
|
||||
card one, and it never stretches: a segmented control that fills the page
|
||||
width puts its two tabs at either end of the screen. */
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tabbar-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--card-radius-sm) - 3px);
|
||||
background: transparent;
|
||||
color: var(--color-ink-3);
|
||||
font: 500 13px/1 var(--font-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
/* Hover is a hint of the track's own surface, not the selected state — an
|
||||
unselected tab that looks selected on hover is the oldest bug in this
|
||||
control. */
|
||||
.tabbar-tab:hover:not([aria-selected='true']) {
|
||||
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
/* Raised out of the track: the card surface, a hairline and the same 1px
|
||||
micro-lift every card in the console carries. That is what makes it read as
|
||||
sitting ON the track rather than tinted inside it. */
|
||||
.tabbar-tab[aria-selected='true'] {
|
||||
background: var(--card-bg);
|
||||
border-color: var(--color-line);
|
||||
box-shadow: var(--card-shadow);
|
||||
color: var(--color-ink-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabbar-tab:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The icon carries the brand on the selected tab and nothing on the others, so
|
||||
colour marks the selection once rather than three times. */
|
||||
.tabbar-icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.tabbar-tab[aria-selected='true'] .tabbar-icon { color: var(--color-brand); }
|
||||
|
||||
.tabbar-label { min-width: 0; }
|
||||
|
||||
/* ── The count ──────────────────────────────────────────────────────────────
|
||||
A figure, not a badge.
|
||||
|
||||
Three of the six copies filled this with `--color-warning` — the amber this
|
||||
console uses for something needing attention — so a tab reading "Orders 85"
|
||||
announced eighty-five problems. It is set as a quiet tabular number that
|
||||
takes the brand only on the selected tab, which is the one whose count the
|
||||
reader is actually acting on. */
|
||||
.tabbar-count {
|
||||
flex: none;
|
||||
font: 600 11.5px/1 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-4);
|
||||
padding: 3px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-ink-4) 10%, transparent);
|
||||
}
|
||||
.tabbar-tab[aria-selected='true'] .tabbar-count {
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* ── Live dot ───────────────────────────────────────────────────────────────
|
||||
For a view that updates by itself. Green rather than the brand, because it
|
||||
reports a STATE — the data is moving — and the brand in this console carries
|
||||
action and identity. It keeps its colour on the selected tab for the same
|
||||
reason. */
|
||||
.tabbar-live {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-success, #1f9d55);
|
||||
animation: tabbar-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes tabbar-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
/* A pulse is decoration; the dot itself is the information, so it stays. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabbar-live { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Group divider ──────────────────────────────────────────────────────────
|
||||
A hairline inside the track, for a tab that belongs to a different family
|
||||
from the one before it — Counter sales beside the app's own Orders and
|
||||
Deliveries. Drawn as a margin plus a border so it sits in the gap rather
|
||||
than on either tab. */
|
||||
.tabbar-tab[data-group-start='true'] {
|
||||
margin-left: 9px;
|
||||
position: relative;
|
||||
}
|
||||
.tabbar-tab[data-group-start='true']::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: var(--color-line);
|
||||
}
|
||||
|
||||
/* ── Secondary strip ────────────────────────────────────────────────────────
|
||||
Under a primary switcher — a status filter, say. Smaller, and with no track
|
||||
of its own, so two rows of tabs do not read as two controls of equal rank. */
|
||||
.tabbar[data-size='sm'] {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-tab {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12.5px;
|
||||
border-radius: var(--card-radius-sm);
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] {
|
||||
background: var(--color-brand-tint);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] .tabbar-count {
|
||||
background: color-mix(in oklab, var(--color-brand) 16%, transparent);
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-icon { display: none; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tabbar-tab { padding: 0 10px; gap: 6px; }
|
||||
.tabbar-icon { display: none; }
|
||||
}
|
||||
406
src/components/trailMap.css
Normal file
406
src/components/trailMap.css
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* The map's frame and its pins.
|
||||
*
|
||||
* Leaflet ships its own stylesheet for the tiles, controls and popups; this
|
||||
* covers only what the console adds — the container, the divIcon pins and the
|
||||
* empty state — plus the two places leaflet's defaults clash with the console.
|
||||
*/
|
||||
|
||||
.trail-map {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.trail-map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Sits over the tiles rather than replacing them: an empty map still shows the
|
||||
region, so "no positions here" reads as an absence in a real place instead of
|
||||
a component that failed to load. */
|
||||
.trail-map-empty {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 500;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The divIcon's own box — leaflet gives it a white background and a border by
|
||||
default, which would frame every pin in a small white square. */
|
||||
.trail-pin-wrap {
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.trail-pin {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--pin, var(--color-brand));
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgb(15 23 42 / 45%);
|
||||
}
|
||||
|
||||
/* A place, not an event. Squared off and a touch larger so a shop reads as the
|
||||
thing the round starts from rather than as one more drop on it. */
|
||||
.trail-pin[data-shape='square'] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* A last-known position, not a live one. Hollow, so the difference between
|
||||
"here now" and "here when they last reported" is visible on the map itself
|
||||
and not only in the popup. */
|
||||
.trail-pin[data-faded='true'] {
|
||||
background: transparent;
|
||||
border-color: var(--pin, var(--color-brand));
|
||||
border-width: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Leaflet's controls and popups default to its own font stack and a blue link
|
||||
colour; both look foreign next to the rest of the console. */
|
||||
.trail-map .leaflet-container {
|
||||
font: inherit;
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
/* ── Leaflet Modern Popup Card ────────────────────────────────────────────── */
|
||||
.trail-map .leaflet-popup {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-content-wrapper {
|
||||
padding: 0 !important;
|
||||
border-radius: var(--card-radius) !important;
|
||||
background: var(--card-bg) !important;
|
||||
/* The console's hairline, not a near-miss of it. The shadow STAYS: this one
|
||||
genuinely floats, over a map, which is what a shadow is for. */
|
||||
border: var(--card-border) !important;
|
||||
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
line-height: 1.4 !important;
|
||||
font-family: inherit !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-tip-container {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-tip {
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15) !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-close-button {
|
||||
top: 7px !important;
|
||||
right: 7px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
border-radius: 50% !important;
|
||||
background: rgba(241, 245, 249, 0.85) !important;
|
||||
color: #64748b !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
text-decoration: none !important;
|
||||
transition: all 0.15s ease !important;
|
||||
z-index: 10 !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-close-button:hover {
|
||||
background: #fee2e2 !important;
|
||||
color: #ef4444 !important;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Card Content Structure */
|
||||
.map-popup-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
max-width: 275px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.map-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 7px;
|
||||
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.map-popup-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.map-popup-badge-icon {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.map-popup-id {
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: #662582;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.map-popup-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #662582;
|
||||
color: #ffffff;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.map-popup-body {
|
||||
padding: 10px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-popup-customer-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.map-popup-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(102, 37, 130, 0.1);
|
||||
color: #662582;
|
||||
font-weight: 800;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-popup-cust-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-popup-customer {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-popup-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.map-popup-address {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.map-popup-address-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.map-popup-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.map-popup-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--pill-color, #662582) 10%, #ffffff);
|
||||
border: 1px solid color-mix(in srgb, var(--pill-color, #662582) 25%, transparent);
|
||||
}
|
||||
|
||||
.map-popup-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--pill-color, #662582);
|
||||
}
|
||||
|
||||
.map-popup-status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--pill-color, #662582);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.map-popup-time {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.map-popup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px 8px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.map-popup-rider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.map-popup-rider-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.map-popup-amount {
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.map-popup-multi-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 6px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.map-popup-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.map-popup-item-id {
|
||||
font-weight: 700;
|
||||
color: #662582;
|
||||
}
|
||||
|
||||
.map-popup-item-status {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.map-popup-item-more {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.map-popup-line {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-control-attribution {
|
||||
font-size: 10px;
|
||||
background: color-mix(in oklab, var(--color-surface) 85%, transparent);
|
||||
}
|
||||
|
||||
.trail-map .leaflet-control-attribution a {
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* A numbered pin carries the stop's place in the round. Bigger than a plain
|
||||
dot because two digits have to stay legible over map detail, and the white
|
||||
ring is what keeps them readable against a dark tile. */
|
||||
.trail-pin[data-numbered='true'] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 1px rgb(15 23 42 / 45%);
|
||||
}
|
||||
|
||||
/* Another round is focused. Faded rather than hidden — a neighbouring rider's
|
||||
stops are still context, and hiding them makes two overlapping rounds
|
||||
impossible to compare. */
|
||||
.trail-pin[data-muted='true'] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* The round's first stop. Ringed rather than recoloured, so it keeps its own
|
||||
status colour while still reading as the start. */
|
||||
.trail-pin[data-start='true'] {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--pin, var(--color-brand)),
|
||||
0 1px 4px rgb(15 23 42 / 45%);
|
||||
}
|
||||
@@ -43,10 +43,29 @@ export interface Paged<T> {
|
||||
to: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 25;
|
||||
/**
|
||||
* Fifteen rows, everywhere, until somebody says otherwise.
|
||||
*
|
||||
* Every table on the console takes this — no call site overrides it — so this
|
||||
* constant IS the console's answer to "how long is a page", and changing it
|
||||
* here changes all of them at once. That is the point: a default that drifts per
|
||||
* screen makes the pager something to re-read on every page rather than
|
||||
* something learnt once.
|
||||
*
|
||||
* It is a default, not a limit. The selector in `TablePager` offers the sizes
|
||||
* below and `setPageSize` keeps whatever is chosen for as long as the table is
|
||||
* mounted, so a person working through a long list can widen it and stay widened.
|
||||
*/
|
||||
export const DEFAULT_PAGE_SIZE = 15;
|
||||
|
||||
/** The choices offered in the page-size selector. */
|
||||
export const PAGE_SIZES = [10, 25, 50, 100];
|
||||
/**
|
||||
* The choices offered in the page-size selector.
|
||||
*
|
||||
* 15 is here because the default has to be selectable — a selector whose current
|
||||
* value is not one of its options has nothing to show as chosen. 10 stays: it is
|
||||
* the "show me less" end, and the default moving up is no reason to take it away.
|
||||
*/
|
||||
export const PAGE_SIZES = [10, 15, 25, 50, 100];
|
||||
|
||||
export function usePaged<T>(
|
||||
rows: readonly T[],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -8,17 +8,12 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import {
|
||||
checkAccount,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
PasswordSetupRequiredError,
|
||||
setInitialPassword,
|
||||
} from '@/auth/session';
|
||||
import { checkAccount, PasswordSetupRequiredError, WrongConsoleError } from '@/auth/session';
|
||||
|
||||
/**
|
||||
* Sign-in — KROW's full-bleed auth archetype.
|
||||
@@ -41,33 +36,61 @@ export function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/* Separate from `error`: the wrong console is guidance, not a failure. So is
|
||||
an account that has not been set up — the way in is the invitation email,
|
||||
and nothing about that is broken. */
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
/**
|
||||
* Which of the three steps is on screen.
|
||||
* Which of the two steps is on screen.
|
||||
*
|
||||
* Email first, always. A tenant made by `createtenantuser` and every branch
|
||||
* made by `createtenantlocation` is spawned with an EMPTY password, so their
|
||||
* owner's first sign-in cannot succeed — and a form that asks for the
|
||||
* password up front asks them for something that does not exist yet. They
|
||||
* guess, it fails, and only then are they told to invent one.
|
||||
* guess, it fails, and only then are they told what is actually wrong.
|
||||
*
|
||||
* So the email is checked before a password field is ever shown, and the page
|
||||
* goes straight to whichever step that account actually needs. This is what
|
||||
* the old console does, and it is the right shape.
|
||||
* So the email is checked before a password field is ever shown. An account
|
||||
* that has no password never reaches the second step: it is told to use its
|
||||
* invitation, because setting a first password happens on `SetPasswordPage`
|
||||
* behind a signed link and nowhere else.
|
||||
*/
|
||||
const [step, setStep] = useState<'email' | 'password' | 'setup'>('email');
|
||||
const [step, setStep] = useState<'email' | 'password'>('email');
|
||||
|
||||
/**
|
||||
* The userid the probe returned for an account with no password.
|
||||
*
|
||||
* Deliberately not a route: it exists only because a check just produced it,
|
||||
* and a `/set-password` URL that could be opened cold would be a way to set
|
||||
* any account's password from nothing.
|
||||
*/
|
||||
const [setupUserid, setSetupUserid] = useState<number | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
/*
|
||||
Each step brings its own panel into view.
|
||||
|
||||
The three steps are one screen, and on a phone the brand panel sits ABOVE the
|
||||
form — so the card is taller than the viewport and answering the email step
|
||||
replaces the panel below the fold. Without this the page does not move: you
|
||||
press Continue, something changes off screen, and the password field you are
|
||||
meant to type into is never shown to you.
|
||||
|
||||
Smooth rather than a jump, because unlike a route change this is a move
|
||||
WITHIN a screen the reader is already looking at, and seeing the page travel
|
||||
is what connects the button they pressed to the field that arrived. It is
|
||||
skipped on the first render — arriving at a login already scrolled to the
|
||||
form would hide the brand and the "which account is this" context above it —
|
||||
and a reader who has asked for reduced motion gets the instant jump, via the
|
||||
global rule in `index.css`.
|
||||
|
||||
DECLARED ABOVE THE `user` REDIRECT BELOW, and that placement is not cosmetic:
|
||||
hooks must run in the same order on every render, and this component returns
|
||||
early the moment a session exists. Put below that line, these two refs and
|
||||
the effect would simply stop being called on the render that signs somebody
|
||||
in — which is React's "rendered fewer hooks than expected" crash, on the
|
||||
happy path.
|
||||
*/
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const isFirstStep = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstStep.current) {
|
||||
isFirstStep.current = false;
|
||||
return;
|
||||
}
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [step]);
|
||||
|
||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
@@ -75,14 +98,24 @@ export function LoginPage() {
|
||||
async function handleEmail(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
/* Cleared on every attempt, not only on the one that sets it. The notice
|
||||
names the address that was typed — leaving it up while a second, valid
|
||||
address is being checked tells the reader the wrong thing about it. */
|
||||
setNotice(null);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const check = await checkAccount(email);
|
||||
if (check.state === 'setup') {
|
||||
setSetupUserid(check.userid);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setStep('setup');
|
||||
// An account that exists and has never been used. It is NOT offered a
|
||||
// password form any more: this branch is reached by anybody who types
|
||||
// an email, so a form here meant that knowing a merchant's address —
|
||||
// usually printed on their shopfront — was enough to claim their
|
||||
// account. The way in is the invitation, and this says so.
|
||||
setNotice(
|
||||
'This account has not been set up yet. Use the link in your invitation email, ' +
|
||||
'or ask whoever set you up to send another.',
|
||||
);
|
||||
setPassword('');
|
||||
} else {
|
||||
setStep('password');
|
||||
}
|
||||
@@ -96,6 +129,7 @@ export function LoginPage() {
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const session = await signIn(email, password);
|
||||
@@ -105,10 +139,24 @@ export function LoginPage() {
|
||||
// check and the submit an administrator could have cleared the password,
|
||||
// and the account would otherwise dead-end on "Invalid Email".
|
||||
if (cause instanceof PasswordSetupRequiredError) {
|
||||
setSetupUserid(cause.userid);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setStep('setup');
|
||||
// Between the probe and the submit, an administrator could have cleared
|
||||
// the password. Same answer as the probe gives: the invitation is the
|
||||
// only way to set one, so there is nothing to offer here.
|
||||
setNotice(
|
||||
'This account has not been set up yet. Use the link in your invitation email, ' +
|
||||
'or ask whoever set you up to send another.',
|
||||
);
|
||||
setPassword('');
|
||||
setStep('email');
|
||||
} else if (cause instanceof WrongConsoleError) {
|
||||
// Not a failure. The password was right and the account is fine — it
|
||||
// belongs to the other console. Shown as guidance rather than as an
|
||||
// error, because somebody told "sign-in failed" in red goes and resets a
|
||||
// password that works. The field is cleared and the step returns to the
|
||||
// email, since retyping the same password here will do the same thing.
|
||||
setNotice(cause.message);
|
||||
setPassword('');
|
||||
setStep('email');
|
||||
} else {
|
||||
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
|
||||
}
|
||||
@@ -117,49 +165,20 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to the email field, from either of the two second steps. */
|
||||
/** Back to the email field, from the password step. */
|
||||
function restart() {
|
||||
setStep('email');
|
||||
setSetupUserid(null);
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the password, then sign in with it.
|
||||
*
|
||||
* Signing in afterwards rather than sending the person back to the form: they
|
||||
* have just typed the password twice, and `applogin` is the only proof the
|
||||
* write actually took.
|
||||
*/
|
||||
async function handleSetup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (setupUserid === null) return;
|
||||
setError(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Those two passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await setInitialPassword(setupUserid, newPassword);
|
||||
const session = await signIn(email, newPassword);
|
||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Could not set the password');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/* `handleSetup` stood here, with its own password form. Setting a first
|
||||
password moved to `SetPasswordPage`, reached only from a signed invitation
|
||||
— this screen is public, so a form here meant knowing a merchant's email
|
||||
was enough to claim their account. */
|
||||
const canSubmit =
|
||||
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
|
||||
const canSetup =
|
||||
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -168,24 +187,38 @@ export function LoginPage() {
|
||||
>
|
||||
<div
|
||||
className="login-split"
|
||||
/* The sign-in card is the console's card, at the console's corner.
|
||||
|
||||
It was the largest radius in the product at 28px, carrying a
|
||||
two-layer drop shadow, and it was the FIRST surface anybody saw —
|
||||
so it set an expectation the rest of the app then did not meet. It
|
||||
is now the same flat hairline card as every other surface, just
|
||||
bigger. */
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 1024,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 28,
|
||||
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
||||
background: 'var(--card-bg)',
|
||||
border: 'var(--card-border)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
boxShadow: 'var(--card-shadow)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<BrandPanel />
|
||||
{step !== 'setup' ? (
|
||||
<FormPanel
|
||||
{/* The ref lives on a wrapper rather than inside the two panels, so
|
||||
neither has to know it is being scrolled to. `display: grid` keeps
|
||||
it out of the way: as a grid item the wrapper stretches to the row,
|
||||
and the panel inside stretches with it, which is what centres the
|
||||
form vertically beside the brand. A plain block here would collapse
|
||||
to its content's height and un-centre it. */}
|
||||
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
|
||||
<FormPanel
|
||||
step={step}
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
error={error}
|
||||
notice={notice}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSubmit}
|
||||
onEmail={setEmail}
|
||||
@@ -193,23 +226,8 @@ export function LoginPage() {
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onSubmit={step === 'email' ? handleEmail : handleSubmit}
|
||||
onBack={restart}
|
||||
/>
|
||||
) : (
|
||||
<SetupPanel
|
||||
email={email}
|
||||
newPassword={newPassword}
|
||||
confirmPassword={confirmPassword}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
canSubmit={canSetup}
|
||||
onNewPassword={setNewPassword}
|
||||
onConfirmPassword={setConfirmPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onSubmit={handleSetup}
|
||||
onBack={restart}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -352,6 +370,8 @@ interface FormPanelProps {
|
||||
password: string;
|
||||
isPasswordVisible: boolean;
|
||||
error: string | null;
|
||||
/** The wrong-console sentence. Rendered beside `error`, never as one. */
|
||||
notice: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onEmail: (value: string) => void;
|
||||
@@ -367,6 +387,7 @@ function FormPanel({
|
||||
password,
|
||||
isPasswordVisible,
|
||||
error,
|
||||
notice,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onEmail,
|
||||
@@ -459,6 +480,7 @@ function FormPanel({
|
||||
something the system does not do. */}
|
||||
|
||||
<ErrorNote message={error} />
|
||||
<ConsoleNote message={notice} />
|
||||
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
@@ -492,174 +514,11 @@ function FormPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Right — first sign-in, setting the password
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface SetupPanelProps {
|
||||
email: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
isPasswordVisible: boolean;
|
||||
error: string | null;
|
||||
isBusy: boolean;
|
||||
canSubmit: boolean;
|
||||
onNewPassword: (value: string) => void;
|
||||
onConfirmPassword: (value: string) => void;
|
||||
onToggleVisible: () => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The second state of this page, not a second page.
|
||||
*
|
||||
* An account created by `createtenantuser` or `createtenantlocation` is spawned
|
||||
* with an empty password, so its owner's first sign-in cannot succeed and there
|
||||
* is no reset email to fall back on. Before this existed the page detected the
|
||||
* condition and then told the person to go and find an administrator — for an
|
||||
* account that was working as designed.
|
||||
*/
|
||||
function SetupPanel({
|
||||
email,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
isPasswordVisible,
|
||||
error,
|
||||
isBusy,
|
||||
canSubmit,
|
||||
onNewPassword,
|
||||
onConfirmPassword,
|
||||
onToggleVisible,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: SetupPanelProps) {
|
||||
const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH;
|
||||
const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword;
|
||||
|
||||
return (
|
||||
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
marginBottom: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-surface-subtle)',
|
||||
border: '1px solid var(--color-line)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.09em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={13} />
|
||||
First sign-in
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 26,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
Choose a password
|
||||
</h1>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
{email} has no password yet. Set one now and we will sign you straight in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Field
|
||||
label="New password"
|
||||
htmlFor="new-password"
|
||||
icon={<Lock size={15} />}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleVisible}
|
||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||
style={eyeButtonStyle}
|
||||
>
|
||||
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<input
|
||||
id="new-password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(event) => onNewPassword(event.target.value)}
|
||||
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
required
|
||||
aria-invalid={isTooShort}
|
||||
style={{ ...inputStyle, paddingRight: 40 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={isPasswordVisible ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(event) => onConfirmPassword(event.target.value)}
|
||||
placeholder="Type it again"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
aria-invalid={isMismatched}
|
||||
style={{
|
||||
...inputStyle,
|
||||
borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Hint>
|
||||
{isTooShort
|
||||
? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.`
|
||||
: isMismatched
|
||||
? 'Those two do not match yet.'
|
||||
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
|
||||
</Hint>
|
||||
|
||||
<ErrorNote message={error} />
|
||||
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
isBusy={isBusy}
|
||||
busyLabel="Setting it…"
|
||||
label="Set password and sign in"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<button type="button" onClick={onBack} style={backLinkStyle}>
|
||||
Use a different account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A quiet line under the fields — advisory, never an error. */
|
||||
function Hint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
/* `SetupPanelProps`, `SetupPanel` and the `Hint` line they used stood here — a
|
||||
second panel with its own password fields. Setting a first password moved to
|
||||
`SetPasswordPage`, reached only from a signed invitation email. This screen
|
||||
is public, so a password form on it meant that knowing a merchant's address
|
||||
was enough to claim their account. */
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Shared form furniture
|
||||
@@ -689,6 +548,41 @@ function ErrorNote({ message }: { message: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The right account at the wrong console.
|
||||
*
|
||||
* Deliberately not `ErrorNote`. Nothing failed: the password was correct and
|
||||
* the account is in good standing — it simply belongs to the other site. Shown
|
||||
* in red beside "Sign-in failed", it sends people to reset a password that
|
||||
* works, or to ask an administrator to fix an account that is not broken.
|
||||
*
|
||||
* `role="status"` rather than `role="alert"` for the same reason: a screen
|
||||
* reader should read this as information, not as something that went wrong.
|
||||
*/
|
||||
function ConsoleNote({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 9,
|
||||
padding: '11px 13px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-surface-sunken, #F4F5F7)',
|
||||
border: '1px solid var(--color-line, #E6E8EB)',
|
||||
color: 'var(--color-ink-2, #52606D)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
<Building2 size={16} style={{ flex: 'none', marginTop: 1 }} />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
canSubmit,
|
||||
isBusy,
|
||||
|
||||
356
src/features/auth/SetPasswordPage.tsx
Normal file
356
src/features/auth/SetPasswordPage.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { CheckCircle2, Eye, EyeOff, KeyRound, Lock } from 'lucide-react';
|
||||
import { MIN_PASSWORD_LENGTH, setInitialPassword } from '@/auth/session';
|
||||
|
||||
/**
|
||||
* Choosing a first password, from the link in an invitation email.
|
||||
*
|
||||
* ── Why this is its own page ────────────────────────────────────────────────
|
||||
*
|
||||
* It used to be a step inside the sign-in screen: type an email, and if the
|
||||
* account had no password the form offered to set one. That was an account
|
||||
* takeover — `applogin` answers an email with NO password, so anybody could
|
||||
* POST a merchant's primary address, receive their userid, and claim the
|
||||
* business. The address is usually printed on the shopfront.
|
||||
*
|
||||
* So setting a first password is no longer something a visitor can ask for. It
|
||||
* happens here, reached only from a signed invitation, and the sign-in screen
|
||||
* now says "check your email" instead of offering a form.
|
||||
*
|
||||
* ── The token never leaves the URL bar ──────────────────────────────────────
|
||||
*
|
||||
* It is read from the query string and sent in the request body. It is not put
|
||||
* into storage, not logged, and not shown on screen: it is a credential with a
|
||||
* seven-day life, and every extra place it rests is another place it leaks
|
||||
* from.
|
||||
*/
|
||||
export function SetPasswordPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const token = params.get('t') ?? '';
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
const isTooShort = password !== '' && password.length < MIN_PASSWORD_LENGTH;
|
||||
const isMismatched = confirm !== '' && password !== confirm;
|
||||
const canSubmit =
|
||||
password.length >= MIN_PASSWORD_LENGTH && password === confirm && !isBusy && token !== '';
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await setInitialPassword(token, password);
|
||||
setIsDone(true);
|
||||
} catch (cause) {
|
||||
// The server's words, not ours. It distinguishes an expired invitation
|
||||
// from a forged one from an account that is already set up, and each
|
||||
// needs a different thing from the person reading it.
|
||||
setError(cause instanceof Error ? cause.message : 'Could not set the password');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
return (
|
||||
<Shell>
|
||||
<div style={{ display: 'grid', gap: 16, justifyItems: 'start' }}>
|
||||
<span style={{ color: 'var(--color-success, #1f9d55)' }}>
|
||||
<CheckCircle2 size={28} />
|
||||
</span>
|
||||
<Heading>Your password is set</Heading>
|
||||
<Body>
|
||||
Sign in with your email address and the password you just chose.
|
||||
</Body>
|
||||
{/* Straight to sign-in rather than signing them in here. Setting a
|
||||
password and holding a session are two different acts, and this
|
||||
page deliberately never holds one. */}
|
||||
<PrimaryButton label="Go to sign in" onClick={() => navigate('/login', { replace: true })} />
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (token === '') {
|
||||
// A truncated link, or one copied without its query string. Said plainly,
|
||||
// because the person's next move is to go back to the email rather than to
|
||||
// try again here.
|
||||
return (
|
||||
<Shell>
|
||||
<div style={{ display: 'grid', gap: 12, justifyItems: 'start' }}>
|
||||
<Heading>This link is incomplete</Heading>
|
||||
<Body>
|
||||
Open the full link from your invitation email. If you no longer have it, ask whoever
|
||||
set up your account to send another.
|
||||
</Body>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: 20 }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<span style={{ color: 'var(--color-brand)' }}>
|
||||
<KeyRound size={26} />
|
||||
</span>
|
||||
<Heading>Choose your password</Heading>
|
||||
<Body>
|
||||
This is the password you will sign in with. It is the last step — your account is
|
||||
already set up and waiting.
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Field label="New password">
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isVisible={isVisible}
|
||||
onToggle={() => setIsVisible((v) => !v)}
|
||||
autoFocus
|
||||
/>
|
||||
<Hint tone={isTooShort ? 'warn' : 'muted'}>
|
||||
{isTooShort
|
||||
? `Use at least ${MIN_PASSWORD_LENGTH} characters.`
|
||||
: `At least ${MIN_PASSWORD_LENGTH} characters.`}
|
||||
</Hint>
|
||||
</Field>
|
||||
|
||||
<Field label="Confirm password">
|
||||
<PasswordInput
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
isVisible={isVisible}
|
||||
onToggle={() => setIsVisible((v) => !v)}
|
||||
/>
|
||||
{isMismatched ? <Hint tone="warn">These do not match.</Hint> : null}
|
||||
</Field>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 9,
|
||||
padding: '11px 13px',
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-error-muted, #FCEEEE)',
|
||||
border: '1px solid rgba(214,69,69,.22)',
|
||||
color: '#B03434',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PrimaryButton
|
||||
label={isBusy ? 'Setting it…' : 'Set password'}
|
||||
isDisabled={!canSubmit}
|
||||
type="submit"
|
||||
/>
|
||||
|
||||
{/* Passwords are stored as typed on this backend. Said here rather than
|
||||
buried in a policy page, because this is the moment somebody decides
|
||||
whether to reuse one from elsewhere. */}
|
||||
<Body isSmall>
|
||||
Passwords on this system are stored as typed. Do not reuse one from another service.
|
||||
</Body>
|
||||
</form>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Local furniture ──────────────────────────────────────────────────────
|
||||
Deliberately not imported from LoginPage. This page is reached by people who
|
||||
have never seen the product, from an email, possibly on a phone — and it must
|
||||
keep working if the sign-in screen is redesigned. */
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 24,
|
||||
background: 'var(--color-surface-sunken, #F4F5F7)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
padding: 32,
|
||||
borderRadius: 16,
|
||||
background: 'var(--color-surface, #fff)',
|
||||
border: '1px solid var(--color-line, #E6E8EB)',
|
||||
boxShadow: '0 12px 32px -12px rgb(16 24 32 / .12)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Heading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ children, isSmall }: { children: React.ReactNode; isSmall?: boolean }) {
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: isSmall ? 11.5 : 13.5,
|
||||
lineHeight: 1.6,
|
||||
color: isSmall ? 'var(--color-ink-4)' : 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-2)' }}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Hint({ children, tone }: { children: React.ReactNode; tone: 'muted' | 'warn' }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: tone === 'warn' ? '#B03434' : 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
isVisible,
|
||||
onToggle,
|
||||
autoFocus,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
isVisible: boolean;
|
||||
onToggle: () => void;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
border: '1px solid var(--color-line, #E6E8EB)',
|
||||
borderRadius: 10,
|
||||
padding: '10px 12px',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<Lock size={15} style={{ flex: 'none', color: 'var(--color-ink-4)' }} />
|
||||
<input
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
// New password, not a saved one — tells a password manager to offer a
|
||||
// generated one rather than autofilling something from another site.
|
||||
autoComplete="new-password"
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 0,
|
||||
outline: 'none',
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-1)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={isVisible ? 'Hide password' : 'Show password'}
|
||||
style={{
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-ink-4)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryButton({
|
||||
label,
|
||||
onClick,
|
||||
isDisabled,
|
||||
type = 'button',
|
||||
}: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
isDisabled?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type === 'submit' ? 'submit' : 'button'}
|
||||
disabled={isDisabled}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
style={{
|
||||
border: 0,
|
||||
borderRadius: 10,
|
||||
padding: '12px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
color: '#fff',
|
||||
background: isDisabled ? 'var(--color-ink-4)' : 'var(--color-brand)',
|
||||
cursor: isDisabled ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
210
src/features/catalogue/BulkImportDrawer.tsx
Normal file
210
src/features/catalogue/BulkImportDrawer.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Switch } from '@astryxdesign/core/Switch';
|
||||
import { nutritionApi } from '@/api/nutrition';
|
||||
import type { CatalogueProduct } from '@/api/types';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerButton, Note } from '@/features/store-admin/drawerKit';
|
||||
import { BAND_LABEL, isEdible, present } from '@/features/store-admin/healthScore';
|
||||
import { catalogueKey } from '@/api/catalogue';
|
||||
|
||||
/**
|
||||
* Adding several products at once, and deciding the health score for each.
|
||||
*
|
||||
* ── Why a drawer here and not on the single-product path ────────────────────
|
||||
*
|
||||
* A single product already has a drawer — the catalogue detail one — which shows
|
||||
* its score and carries the toggle, so a second panel there was repeating what
|
||||
* was already on screen. A batch has no such drawer, so this is the first place
|
||||
* the question can be asked at all.
|
||||
*
|
||||
* ── Why the toggles are per product ─────────────────────────────────────────
|
||||
*
|
||||
* Because a batch is not one decision. Three products can easily be two that
|
||||
* should show a rating and one that should not — a 47%-confidence match on a
|
||||
* product the shopkeeper knows is mis-matched — and a single switch for the
|
||||
* whole selection forces that into a lie either way.
|
||||
*
|
||||
* ── Why each row shows its score ────────────────────────────────────────────
|
||||
*
|
||||
* The same reason the detail drawer does: "show health score?" against a product
|
||||
* name is unanswerable. Beside "22/100 · matched at 47%" it answers itself. The
|
||||
* rows that have no score say so and carry no toggle, so nobody sets a control
|
||||
* that decides nothing.
|
||||
*/
|
||||
export function BulkImportDrawer({
|
||||
products,
|
||||
actionLabel,
|
||||
isBusy,
|
||||
defaultShowScore,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
products: CatalogueProduct[];
|
||||
actionLabel: string;
|
||||
isBusy: boolean;
|
||||
/** The screen's setting, which every row starts at. */
|
||||
defaultShowScore: boolean;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Keyed by `catalogueKey`. A product missing from the map has no score, and
|
||||
* the import says nothing about it rather than sending a choice nobody made.
|
||||
*/
|
||||
onConfirm: (choices: Map<string, boolean>) => void;
|
||||
}) {
|
||||
/*
|
||||
Only the rows the shopkeeper has actually moved.
|
||||
|
||||
Starting empty rather than pre-filling every product with the default means
|
||||
"untouched" and "deliberately set to the default" stay the same thing, which
|
||||
they are — and a row whose score never loads cannot end up contributing a
|
||||
choice about a score nobody saw.
|
||||
*/
|
||||
const [choices, setChoices] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
const choiceFor = (key: string) => choices.get(key) ?? defaultShowScore;
|
||||
const setChoice = (key: string, show: boolean) =>
|
||||
setChoices((previous) => new Map(previous).set(key, show));
|
||||
|
||||
function setAll(show: boolean) {
|
||||
setChoices(new Map(products.map((product) => [catalogueKey(product), show])));
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`Add ${products.length} products`}
|
||||
subtitle="Choose which of these show a health score"
|
||||
width={520}
|
||||
onClose={onCancel}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onCancel} />
|
||||
<DrawerButton
|
||||
label={isBusy ? 'Adding…' : `${actionLabel} (${products.length})`}
|
||||
variant="primary"
|
||||
isDisabled={isBusy}
|
||||
onClick={() => onConfirm(choices)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Note>
|
||||
The nutrition figures are shown either way — this is only the rating. Any of these can be
|
||||
changed later from the product.
|
||||
</Note>
|
||||
|
||||
{/* For the ordinary case, where the whole batch goes the same way. Without
|
||||
it a shopkeeper who wants forty products' ratings off has forty clicks
|
||||
to make, which is how a feature becomes one nobody uses. */}
|
||||
<div style={{ display: 'flex', gap: 8, margin: '12px 0 4px' }}>
|
||||
<DrawerButton label="Show all" variant="secondary" onClick={() => setAll(true)} />
|
||||
<DrawerButton label="Hide all" variant="secondary" onClick={() => setAll(false)} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid' }}>
|
||||
{products.map((product) => (
|
||||
<BulkImportRow
|
||||
key={catalogueKey(product)}
|
||||
product={product}
|
||||
isShown={choiceFor(catalogueKey(product))}
|
||||
onChange={(show) => setChoice(catalogueKey(product), show)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One product in the batch, with its score and its toggle.
|
||||
*
|
||||
* Each row asks for its own score. They are the same lookups the product drawer
|
||||
* would make, cached for an hour by the query client and shared with it — so
|
||||
* opening a product after a batch costs nothing, and re-opening this drawer
|
||||
* costs nothing either.
|
||||
*/
|
||||
function BulkImportRow({
|
||||
product,
|
||||
isShown,
|
||||
onChange,
|
||||
}: {
|
||||
product: CatalogueProduct;
|
||||
isShown: boolean;
|
||||
onChange: (show: boolean) => void;
|
||||
}) {
|
||||
const brand = (product.brand ?? '').trim();
|
||||
const imageId = (product.image_id ?? '').trim();
|
||||
|
||||
// Non-food never has a score — the service has rated insecticide 80/100, so
|
||||
// an allowlist gates it — and asking about one is a request for nothing.
|
||||
const isFood = isEdible(product.category);
|
||||
const query = useQuery({
|
||||
queryKey: ['nutrition', brand, imageId],
|
||||
queryFn: () => nutritionApi.forProduct(brand, imageId),
|
||||
enabled: Boolean(brand && imageId && isFood),
|
||||
staleTime: 60 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const shown = present(query.data ?? null);
|
||||
const hasScore = isFood && !shown.isEmpty && !shown.isPending && shown.score !== null;
|
||||
const isChecking = isFood && Boolean(brand && imageId) && query.isLoading;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '11px 0',
|
||||
borderTop: '1px solid var(--color-line, #e0e4ea)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-ink-1)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{product.product_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--color-ink-4)', marginTop: 2 }}>
|
||||
{isChecking
|
||||
? 'Checking…'
|
||||
: hasScore
|
||||
? `${shown.display}/100 · ${shown.band ? BAND_LABEL[shown.band] : ''}${
|
||||
shown.caveat ? ` · ${shown.caveat.match(/\d+%/)?.[0] ?? ''} match` : ''
|
||||
}`
|
||||
: isFood
|
||||
? 'No health score yet'
|
||||
: 'Not food — no health score'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasScore ? (
|
||||
/* The label is hidden visually and kept for a screen reader: the product
|
||||
name sitting beside it is what a sighted reader uses, and repeating
|
||||
"Show health score for Britannia Good Day…" on every row would be the
|
||||
same sentence forty times down a list. */
|
||||
<Switch
|
||||
label={`Show the health score for ${product.product_name}`}
|
||||
isLabelHidden
|
||||
value={isShown}
|
||||
onChange={onChange}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
/* No toggle, and the space kept, so the rows do not jag left and right
|
||||
down the list as scores resolve at different speeds. */
|
||||
<span style={{ width: 44 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Switch } from '@astryxdesign/core/Switch';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Token } from '@astryxdesign/core/Token';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
||||
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
||||
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
||||
import { APP_BROWSE_CATEGORY } from './tenantCategories';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from '@/queries/hooks';
|
||||
import { CatalogueCard } from './CatalogueCard';
|
||||
import { CatalogueSidebar } from './CatalogueSidebar';
|
||||
import { BulkImportDrawer } from './BulkImportDrawer';
|
||||
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
@@ -93,6 +95,30 @@ export function CatalogueBrowser({
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
||||
/*
|
||||
Whether products added from this screen show their health score.
|
||||
|
||||
Two of the three ways to add a product never open the drawer — a card's Add
|
||||
is one click and a bulk add is one click for forty — so without this they
|
||||
had no say at all, and "showing" was decided for them.
|
||||
|
||||
One control for the whole screen rather than a question per product: a
|
||||
prompt on every card add is the thing that was taken out, and a toggle asked
|
||||
once for forty products is a question nobody answers honestly. The drawer's
|
||||
own toggle still wins for the product it is open on, because somebody
|
||||
reading that score has better information than this default does.
|
||||
*/
|
||||
const [showScoreOnAdd, setShowScoreOnAdd] = useState(true);
|
||||
/*
|
||||
The batch waiting on its per-product choices.
|
||||
|
||||
A selection is not one decision: three products can be two whose rating
|
||||
should show and one whose should not, and a single switch for the whole set
|
||||
forces that into a lie either way. Unlike the single-product path, a batch
|
||||
has no drawer of its own — so this is the first place the question can be
|
||||
asked at all rather than a second panel repeating one.
|
||||
*/
|
||||
const [batch, setBatch] = useState<CatalogueProduct[] | null>(null);
|
||||
const [open, setOpen] = useState<CatalogueProduct | null>(null);
|
||||
const [category, setCategory] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -196,15 +222,28 @@ export function CatalogueBrowser({
|
||||
one-argument builder and stayed valid the moment the second argument
|
||||
arrived.
|
||||
*/
|
||||
mutationFn: async (products: CatalogueProduct[]) => {
|
||||
mutationFn: async ({
|
||||
products,
|
||||
choices,
|
||||
}: {
|
||||
products: CatalogueProduct[];
|
||||
/* Keyed by `catalogueKey`. A product missing from the map takes the
|
||||
screen's setting — the import request carries one value per row, so a
|
||||
mixed batch is one call, not one per choice. */
|
||||
choices: Map<string, boolean>;
|
||||
}) => {
|
||||
const ids = await aisleIds();
|
||||
return productsApi.importFromCatalogue(
|
||||
products.map((product) =>
|
||||
importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids)),
|
||||
importRowFor(
|
||||
product,
|
||||
aisleIdForCategory(categoryNameFor(product), ids),
|
||||
choices.get(catalogueKey(product)) ?? showScoreOnAdd,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: async (_result, products) => {
|
||||
onSuccess: async (_result, { products }) => {
|
||||
// Ticked locally as well as refetched: the imported list is a separate
|
||||
// query and the grid would otherwise show them as un-imported until it
|
||||
// came back, tempting a second click.
|
||||
@@ -296,6 +335,7 @@ export function CatalogueBrowser({
|
||||
function importRowFor(
|
||||
product: CatalogueProduct,
|
||||
subcategoryid: number,
|
||||
showHealthScore: boolean | undefined,
|
||||
): ImportCatalogueProductRequest {
|
||||
return {
|
||||
tenantid: tenantid as number,
|
||||
@@ -318,19 +358,59 @@ export function CatalogueBrowser({
|
||||
retailprice: 0,
|
||||
productcost: 0,
|
||||
taxpercent: 0,
|
||||
/* Omitted when the product has no score, so the import says nothing about
|
||||
it rather than sending a choice nobody was offered. */
|
||||
...(showHealthScore === undefined ? {} : { showhealthscore: showHealthScore }),
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
Add straight from the card, in one click, as it has always been.
|
||||
|
||||
No question asked here, deliberately. The health score toggle lives in the
|
||||
product's own drawer, where the score and its confidence are on screen — and
|
||||
a card has neither, so a toggle on this path would be asking somebody to
|
||||
judge a rating they cannot see.
|
||||
|
||||
The product imports with its score showing, which is the default and what
|
||||
every product did before the toggle existed. Anyone who wants it off opens
|
||||
the product and turns it off, which is the same control in the same place as
|
||||
changing their mind later.
|
||||
*/
|
||||
async function importDirect(product: CatalogueProduct) {
|
||||
if (!tenantid || !locationid) return;
|
||||
await confirmImport(product, showScoreOnAdd);
|
||||
}
|
||||
|
||||
/*
|
||||
The import, once the shopkeeper has answered.
|
||||
|
||||
One path for a single card and for a batch, so the two cannot write
|
||||
different rows — the single-product button taking a different route from the
|
||||
bulk action is how a selection quietly imports under a different category.
|
||||
*/
|
||||
/*
|
||||
The import, once the shopkeeper has answered.
|
||||
|
||||
`showHealthScore` is undefined for a product with no score — the import then
|
||||
says nothing about it rather than sending a choice nobody was offered, and
|
||||
the backend reads an absent field as yes.
|
||||
*/
|
||||
async function confirmImport(product: CatalogueProduct, showHealthScore: boolean | undefined) {
|
||||
if (!tenantid || !locationid) return;
|
||||
|
||||
const key = catalogueKey(product);
|
||||
setBusy(key);
|
||||
try {
|
||||
await importOne.mutateAsync(
|
||||
importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())),
|
||||
importRowFor(
|
||||
product,
|
||||
aisleIdForCategory(categoryNameFor(product), await aisleIds()),
|
||||
showHealthScore,
|
||||
),
|
||||
);
|
||||
setJustImported((set) => new Set(set).add(key));
|
||||
setOpen(null);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -377,31 +457,36 @@ export function CatalogueBrowser({
|
||||
|
||||
It stays above both columns rather than inside the rail: the search
|
||||
narrows the whole catalogue, and the rail only lists brands. */}
|
||||
<HStack gap={1} align="center" wrap="wrap" justify="end">
|
||||
{/* Right-hand corner. The filter toggle and the search belong at the end
|
||||
of the row, matching where search sits on Sales, Reports and Products,
|
||||
rather than above the brand rail where they read as the rail's own
|
||||
controls instead of the whole catalogue's. */}
|
||||
<HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
|
||||
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<IconButton
|
||||
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
|
||||
icon={<Funnel size={15} />}
|
||||
variant={isFiltersOpen ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<SearchInput
|
||||
label="Search the catalogue"
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
width="full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilters.map((filter) => (
|
||||
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
|
||||
))}
|
||||
{activeFilters.length > 1 ? (
|
||||
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
|
||||
) : null}
|
||||
<IconButton
|
||||
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
|
||||
icon={<Funnel size={15} />}
|
||||
variant={isFiltersOpen ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Search the catalogue"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
width={220}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search products…"
|
||||
startIcon={<Search size={14} />}
|
||||
hasClear
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
|
||||
@@ -411,7 +496,11 @@ export function CatalogueBrowser({
|
||||
isLoading={brands.isLoading}
|
||||
brand={brand}
|
||||
category={category}
|
||||
categories={(categories.data ?? []).filter(Boolean)}
|
||||
/* The platform catalogue filters on the category name itself, so
|
||||
value and label are the same string here. */
|
||||
categories={(categories.data ?? [])
|
||||
.filter(Boolean)
|
||||
.map((name) => ({ value: name, label: name }))}
|
||||
isLoadingCategories={categories.isLoading}
|
||||
onBrand={setBrand}
|
||||
onCategory={setCategory}
|
||||
@@ -489,6 +578,20 @@ export function CatalogueBrowser({
|
||||
</Text>
|
||||
</label>
|
||||
|
||||
{/* The decision for everything added from this screen.
|
||||
|
||||
Beside the add controls rather than in a settings panel,
|
||||
because it is read at the moment it applies — somebody
|
||||
about to add forty products can see what those forty will
|
||||
do. Ticked by default, which is what every product did
|
||||
before the toggle existed. */}
|
||||
<Switch
|
||||
label="Show health score on products I add"
|
||||
value={showScoreOnAdd}
|
||||
onChange={setShowScoreOnAdd}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{selection.count > 0 ? (
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Button
|
||||
@@ -508,10 +611,13 @@ export function CatalogueBrowser({
|
||||
size="sm"
|
||||
isLoading={importMany.isPending}
|
||||
isDisabled={importMany.isPending}
|
||||
/* The whole batch takes the screen's setting — the
|
||||
checkbox beside this button. Asking per product
|
||||
across a selection of forty is a question nobody
|
||||
answers honestly, and any of them can still be
|
||||
changed afterwards from the product. */
|
||||
onClick={() =>
|
||||
importMany.mutate(
|
||||
rows.filter((product) => selection.has(product.id)),
|
||||
)
|
||||
setBatch(rows.filter((product) => selection.has(product.id)))
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
@@ -563,6 +669,21 @@ export function CatalogueBrowser({
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
{batch && batch.length > 0 ? (
|
||||
<BulkImportDrawer
|
||||
products={batch}
|
||||
actionLabel={actionLabel}
|
||||
isBusy={importMany.isPending}
|
||||
defaultShowScore={showScoreOnAdd}
|
||||
onCancel={() => setBatch(null)}
|
||||
onConfirm={(choices) => {
|
||||
const products = batch;
|
||||
setBatch(null);
|
||||
importMany.mutate({ products, choices });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<CatalogueDetailDrawer
|
||||
product={open}
|
||||
@@ -571,10 +692,17 @@ export function CatalogueBrowser({
|
||||
actionLabel={actionLabel}
|
||||
{...(canImport && !isReadOnly
|
||||
? {
|
||||
onImport: () => {
|
||||
onImport: (showHealthScore: boolean | undefined) => {
|
||||
const product = open;
|
||||
if (onImport) setOpen(null);
|
||||
run(product);
|
||||
// A caller that owns the import — the shelving flow — takes
|
||||
// over here and the drawer closes. Otherwise this writes the
|
||||
// row itself, carrying the shopkeeper's answer.
|
||||
if (onImport) {
|
||||
setOpen(null);
|
||||
onImport(product);
|
||||
return;
|
||||
}
|
||||
void confirmImport(product, showHealthScore);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { Check, ChevronLeft, ChevronRight, Eye, ImageOff, Plus } from 'lucide-react';
|
||||
import { Check, ChevronLeft, ChevronRight, ImageOff, Plus } from 'lucide-react';
|
||||
import type { CatalogueProduct } from '@/api/types';
|
||||
|
||||
/**
|
||||
@@ -145,13 +145,6 @@ export function CatalogueCard({
|
||||
what the packaging itself carries in larger type than we could. */}
|
||||
{isImported ? <span className="pcard-owned">In your list</span> : null}
|
||||
|
||||
{/* The action rail. One icon, because one is all we have a use for —
|
||||
the reference's wishlist and compare have nothing behind them. */}
|
||||
<span className="pcard-rail">
|
||||
<span className="pcard-railbtn" aria-hidden="true">
|
||||
<Eye size={14} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* The photo switcher. Only when there is more than one to switch to —
|
||||
an arrow that does nothing is worse than no arrow, and most of a
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
Bullets,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Metric,
|
||||
Metrics,
|
||||
Mono,
|
||||
Row,
|
||||
Section,
|
||||
} from '@/features/store-admin/drawerKit';
|
||||
@@ -41,7 +44,14 @@ export interface CatalogueDetailDrawerProps {
|
||||
isBusy?: boolean;
|
||||
actionLabel?: string;
|
||||
/** Absent when the caller has nowhere to import to yet. */
|
||||
onImport?: () => void;
|
||||
/**
|
||||
* Import this product.
|
||||
*
|
||||
* `showHealthScore` is the shopkeeper's answer to the toggle below the score,
|
||||
* and `undefined` when this product has none — the import then says nothing
|
||||
* about it rather than sending a choice nobody was offered.
|
||||
*/
|
||||
onImport?: (showHealthScore: boolean | undefined) => void;
|
||||
/** Shown in place of the action when importing is unavailable. */
|
||||
blockedReason?: string;
|
||||
onClose: () => void;
|
||||
@@ -73,6 +83,19 @@ export function CatalogueDetailDrawer({
|
||||
const aisle = aisleForCategory(suggested.category);
|
||||
|
||||
const images = product.images ?? [];
|
||||
/*
|
||||
Whether this shop will show the product's health score, and whether there is
|
||||
one to show.
|
||||
|
||||
`hasScore` is reported up by the panel rather than looked up again here: the
|
||||
panel already makes that call, already caches it, and already knows the
|
||||
three ways a product can have no score — not food, not known to the service,
|
||||
known but unscored. Asking a second time would be a second request and a
|
||||
second opinion.
|
||||
*/
|
||||
const [showScore, setShowScore] = useState(true);
|
||||
const [hasScore, setHasScore] = useState(false);
|
||||
|
||||
const [heroAt, setHeroAt] = useState(0);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -102,7 +125,7 @@ export function CatalogueDetailDrawer({
|
||||
subtitle={`${brand} · catalogue id ${product.id}`}
|
||||
width={540}
|
||||
onClose={onClose}
|
||||
{...(isImported ? { meta: <Badge label="In your list" colour="#0f8a5f" /> } : {})}
|
||||
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
|
||||
{...(!isImported && !blockedReason && onImport
|
||||
? {
|
||||
isFooterFilled: true,
|
||||
@@ -112,7 +135,7 @@ export function CatalogueDetailDrawer({
|
||||
variant="primary"
|
||||
icon={<DownloadCloud size={15} />}
|
||||
isDisabled={Boolean(isBusy)}
|
||||
onClick={onImport}
|
||||
onClick={() => onImport(hasScore ? showScore : undefined)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
@@ -162,7 +185,7 @@ export function CatalogueDetailDrawer({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ font: '400 12.5px/1.4 var(--font-sans)', color: 'var(--color-ink-4)' }}>
|
||||
<span className="drawer-metric-note">
|
||||
{images.length} photos — only the first is imported
|
||||
</span>
|
||||
</>
|
||||
@@ -171,31 +194,13 @@ export function CatalogueDetailDrawer({
|
||||
|
||||
{/* A RANGE, not a price. What the shop charges is set after the import,
|
||||
and conflating the two is how a catalogue figure ends up on a shelf. */}
|
||||
<DrawerCard tone="brand">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '14px 16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span className="drawer-metric-label">Market price range</span>
|
||||
<span
|
||||
style={{
|
||||
font: '600 22px/1.2 var(--font-sans)',
|
||||
color: 'var(--color-ink-1)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{product.price_range ?? '—'}
|
||||
</span>
|
||||
</span>
|
||||
{product.size ? <Badge label={product.size} colour="var(--color-brand)" /> : null}
|
||||
</div>
|
||||
</DrawerCard>
|
||||
{/* The kit's own metric, not a 22px figure typed here. It was the largest
|
||||
type in any drawer in the console and it sat on a catalogue REFERENCE
|
||||
— louder than the selling price in the product drawer next to it. */}
|
||||
<Metrics cols={2}>
|
||||
<Metric label="Market price range" value={product.price_range ?? '—'} />
|
||||
<Metric label="Pack size" value={product.size || '—'} isSmall />
|
||||
</Metrics>
|
||||
|
||||
{product.description ? (
|
||||
<Section title="Description">
|
||||
@@ -212,9 +217,19 @@ export function CatalogueDetailDrawer({
|
||||
|
||||
`brand` and `image_id` come straight off the catalogue row, so no
|
||||
lookup is needed to find the key. */}
|
||||
{/* The decision and the thing being decided about, in one place.
|
||||
|
||||
"Show health score?" against a product name is unanswerable; the same
|
||||
question directly beneath "22/100 — Less healthy, matched at 47%
|
||||
confidence" answers itself. That is the whole reason the toggle lives
|
||||
here rather than in a confirmation of its own. */}
|
||||
<HealthScorePanel
|
||||
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
|
||||
category={product.category}
|
||||
onScoreResolved={setHasScore}
|
||||
{...(hasScore && !isImported && onImport
|
||||
? { showToggle: { isShown: showScore, onChange: setShowScore } }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{product.highlights?.length || product.nutrients?.length ? (
|
||||
@@ -243,7 +258,7 @@ export function CatalogueDetailDrawer({
|
||||
label={fact.label}
|
||||
value={
|
||||
fact.isMono ? (
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span>
|
||||
<Mono>{fact.value}</Mono>
|
||||
) : (
|
||||
fact.value
|
||||
)
|
||||
@@ -282,21 +297,21 @@ export function CatalogueDetailDrawer({
|
||||
label="Shown in the app under"
|
||||
value={
|
||||
aisle ? (
|
||||
<Badge label={aisle} colour="#0f8a5f" />
|
||||
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
<span className="drawer-caption">
|
||||
{suggested.rule === 'catalogue'
|
||||
? `The catalogue's own category for this product.`
|
||||
: product.category
|
||||
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
|
||||
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
|
||||
</span>
|
||||
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
<span className="drawer-caption">
|
||||
{aisle
|
||||
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
|
||||
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { CatalogueBrand } from '@/api/types';
|
||||
|
||||
const COLLAPSED_COUNT = 8;
|
||||
const COLLAPSED_COUNT = 25;
|
||||
|
||||
/**
|
||||
* The brand rail.
|
||||
@@ -28,6 +28,7 @@ export function CatalogueSidebar({
|
||||
category,
|
||||
categories,
|
||||
isLoadingCategories,
|
||||
totalCount,
|
||||
onBrand,
|
||||
onCategory,
|
||||
}: {
|
||||
@@ -35,8 +36,26 @@ export function CatalogueSidebar({
|
||||
isLoading: boolean;
|
||||
brand: string;
|
||||
category: string;
|
||||
categories: string[];
|
||||
/**
|
||||
* Value and label kept apart.
|
||||
*
|
||||
* The platform catalogue filters on the category NAME, so the two are the same
|
||||
* string there. A store's own products are grouped by aisle, where the filter
|
||||
* is a `subcategoryid` and the name is what a reader recognises — passing one
|
||||
* string for both would put "7" in the rail or filter on "Dairy".
|
||||
*/
|
||||
categories: { value: string; label: string }[];
|
||||
isLoadingCategories: boolean;
|
||||
/**
|
||||
* What "All brands" counts, when it is not the sum of the rail.
|
||||
*
|
||||
* The platform catalogue's brands account for every row, so summing them is
|
||||
* right there. A store's rows can have no brand at all: those are left out of
|
||||
* the rail and still listed under "All brands", so the sum would be short by
|
||||
* exactly the unbranded items and the top of the rail would contradict the
|
||||
* list beside it.
|
||||
*/
|
||||
totalCount?: number;
|
||||
onBrand: (brand: string) => void;
|
||||
onCategory: (category: string) => void;
|
||||
}) {
|
||||
@@ -56,7 +75,9 @@ export function CatalogueSidebar({
|
||||
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
|
||||
All brands
|
||||
<span className="rail-count">
|
||||
{isLoading ? '' : brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0)}
|
||||
{isLoading
|
||||
? ''
|
||||
: (totalCount ?? brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0))}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -105,15 +126,15 @@ export function CatalogueSidebar({
|
||||
>
|
||||
All categories
|
||||
</button>
|
||||
{categories.map((name) => (
|
||||
{categories.map((entry) => (
|
||||
<button
|
||||
key={name}
|
||||
key={entry.value}
|
||||
type="button"
|
||||
className="rail-category"
|
||||
data-active={category === name}
|
||||
onClick={() => onCategory(category === name ? '' : name)}
|
||||
data-active={category === entry.value}
|
||||
onClick={() => onCategory(category === entry.value ? '' : entry.value)}
|
||||
>
|
||||
{name}
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
|
||||
55
src/features/catalogue/tenantBrands.test.ts
Normal file
55
src/features/catalogue/tenantBrands.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { Product } from '@/api/types';
|
||||
import { matchesBrand, tenantBrands } from './tenantBrands';
|
||||
|
||||
const p = (productbrand?: string, productid = 0) => ({ productid, productbrand }) as Product;
|
||||
|
||||
test('counts only the brands the shop actually carries', () => {
|
||||
const brands = tenantBrands([p('Amul'), p('Britannia'), p('Amul'), p('Amul')]);
|
||||
assert.deepEqual(brands, [
|
||||
{ brand: 'Amul', product_count: 3 },
|
||||
{ brand: 'Britannia', product_count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('one shelf, however the row was typed', () => {
|
||||
// An import writes "BRITANNIA", somebody types "Britannia". A shopkeeper has
|
||||
// one Britannia shelf, so splitting the rail in two would be wrong.
|
||||
const brands = tenantBrands([p('Britannia'), p('BRITANNIA'), p(' britannia ')]);
|
||||
assert.equal(brands.length, 1);
|
||||
assert.equal(brands[0]?.product_count, 3);
|
||||
assert.equal(brands[0]?.brand, 'Britannia', 'the first spelling seen is the one shown');
|
||||
});
|
||||
|
||||
test('rows with no brand are left out of the rail', () => {
|
||||
// They stay in the unfiltered list, which is why the rail is handed the real
|
||||
// total rather than the sum of these counts.
|
||||
const brands = tenantBrands([p('Amul'), p(''), p(undefined), p(' ')]);
|
||||
assert.deepEqual(brands, [{ brand: 'Amul', product_count: 1 }]);
|
||||
});
|
||||
|
||||
test('biggest shelf first, alphabetical within a tie', () => {
|
||||
const brands = tenantBrands([p('Zebra'), p('Apple'), p('Mango'), p('Mango')]);
|
||||
assert.deepEqual(
|
||||
brands.map((b) => b.brand),
|
||||
['Mango', 'Apple', 'Zebra'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a listed brand can never filter to nothing', () => {
|
||||
const rows = [p('Amul'), p('BRITANNIA'), p(undefined)];
|
||||
for (const entry of tenantBrands(rows)) {
|
||||
assert.ok(
|
||||
rows.some((row) => matchesBrand(row, entry.brand)),
|
||||
`${entry.brand} is offered but matches no row`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no brand chosen means no filter', () => {
|
||||
assert.equal(matchesBrand(p(undefined), ''), true);
|
||||
assert.equal(matchesBrand(p('Amul'), ''), true);
|
||||
assert.equal(matchesBrand(p(undefined), 'Amul'), false);
|
||||
assert.equal(matchesBrand(p('amul'), 'Amul'), true, 'matching ignores case like the grouping does');
|
||||
});
|
||||
50
src/features/catalogue/tenantBrands.ts
Normal file
50
src/features/catalogue/tenantBrands.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CatalogueBrand, Product } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The brands a SHOP actually stocks, counted from its own rows.
|
||||
*
|
||||
* The platform catalogue's brands are Postgres tables, discovered from
|
||||
* `information_schema` by `getbrands`, which reports a row count per table. None
|
||||
* of that applies here. A store's products come from `getlocationproducts`, and
|
||||
* the brand is a plain `productbrand` string on each row — so feeding the rail
|
||||
* `useCatalogueBrands()` would list every brand on the platform, most of which
|
||||
* the shop has never carried, each with a count belonging to a different table.
|
||||
* The filter would be full of dead options and the numbers beside them would be
|
||||
* about somebody else's catalogue.
|
||||
*
|
||||
* Counting the rows in hand is the only source that answers "which brands are on
|
||||
* MY shelves", and it has the property the rail needs: picking a brand can never
|
||||
* return nothing, because the brand is only listed if something has it.
|
||||
*
|
||||
* Grouped case-insensitively — "Britannia" and "BRITANNIA" are one brand to a
|
||||
* shopkeeper, and an import that disagrees with a hand-typed row should not
|
||||
* split the shelf in two. The first spelling seen is the one shown, since there
|
||||
* is no authority here to normalise against.
|
||||
*/
|
||||
export function tenantBrands(products: readonly Product[]): CatalogueBrand[] {
|
||||
const byKey = new Map<string, CatalogueBrand>();
|
||||
|
||||
for (const product of products) {
|
||||
const name = product.productbrand?.trim();
|
||||
/* No brand is not a brand. These stay out of the rail and remain visible
|
||||
under "All brands" — which is why the rail is given the real total
|
||||
separately rather than summing these counts. */
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
const seen = byKey.get(key);
|
||||
if (seen) seen.product_count += 1;
|
||||
else byKey.set(key, { brand: name, product_count: 1 });
|
||||
}
|
||||
|
||||
/* Biggest shelf first, alphabetical within a tie. A shop's own list is short
|
||||
enough that this is the whole ordering question. */
|
||||
return [...byKey.values()].sort(
|
||||
(a, b) => b.product_count - a.product_count || a.brand.localeCompare(b.brand),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a row belongs to the chosen brand. `''` means no brand filter. */
|
||||
export function matchesBrand(product: Product, brand: string): boolean {
|
||||
if (!brand) return true;
|
||||
return (product.productbrand?.trim().toLowerCase() ?? '') === brand.trim().toLowerCase();
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { useMemo } from 'react';
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||
import { summariseBranch } from '@/features/store-admin/posStatus';
|
||||
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
|
||||
import {
|
||||
useLocationProducts,
|
||||
useLocationSummary,
|
||||
useOrders,
|
||||
usePosHealthByBranch,
|
||||
usePosSalesByBranch,
|
||||
useStockRequests,
|
||||
@@ -52,7 +53,15 @@ export function ConsolePage() {
|
||||
const dates = useDateScope();
|
||||
|
||||
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||
const orders = useLocationSummary(tenantid || undefined);
|
||||
/* The same rows `useConsoleSeries` reads, so this shares its cache entry
|
||||
rather than adding a request. `getlocationsummary`, which used to feed this
|
||||
table, carries no money and ignores the date picker — see `branchStats.ts`. */
|
||||
const orders = useOrders(
|
||||
tenantid
|
||||
? { tenantid, ...(selected ? { locationid: selected } : {}), ...dates.range, pagesize: 500 }
|
||||
: undefined,
|
||||
);
|
||||
const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]);
|
||||
const posNow = usePosSalesByBranch(branchIds, dates.range);
|
||||
const posHealth = usePosHealthByBranch(branchIds);
|
||||
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
|
||||
@@ -75,26 +84,26 @@ export function ConsolePage() {
|
||||
const rows = useMemo<BranchRow[]>(
|
||||
() =>
|
||||
scoped.map((branch, index) => {
|
||||
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
|
||||
const order = byBranch.get(branch.locationid) ?? NO_ORDERS;
|
||||
const pos = posNow[index]?.data;
|
||||
return {
|
||||
branch,
|
||||
// `getlocationsummary` carries no date filter, so it is used for the
|
||||
// per-branch split the table needs. The dated period figures come
|
||||
// from the order rows — see `totals` below.
|
||||
onlineRevenue: order?.revenue ?? 0,
|
||||
onlineOrders: order?.totalorders ?? 0,
|
||||
cancelled: order?.cancelled ?? 0,
|
||||
delivered: order?.delivered ?? 0,
|
||||
counterRevenue: pos?.grosssales ?? 0,
|
||||
counterBills: pos?.billcount ?? 0,
|
||||
onlineRevenue: order.revenue,
|
||||
onlineOrders: order.orders,
|
||||
cancelled: order.cancelled,
|
||||
delivered: order.delivered,
|
||||
// Both routes to a counter sale — a till that synced, and a till
|
||||
// that was offline whose day was imported as OFFLINE-tagged orders.
|
||||
// See the note in the Store Admin console, which had the same gap.
|
||||
counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue,
|
||||
counterBills: (pos?.billcount ?? 0) + order.counterOrders,
|
||||
health: summariseBranch(posHealth[index]?.data ?? [], now),
|
||||
pendingRequests: (requests.data ?? []).filter(
|
||||
(entry) => entry.locationid === branch.locationid,
|
||||
).length,
|
||||
};
|
||||
}),
|
||||
[scoped, orders.data, posNow, posHealth, requests.data, now],
|
||||
[scoped, byBranch, posNow, posHealth, requests.data, now],
|
||||
);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
|
||||
@@ -63,40 +63,84 @@
|
||||
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
|
||||
}
|
||||
|
||||
/* Icon beside the figure, nothing beneath it. The card used to carry a trend
|
||||
row and a sparkline; with those gone it is a label and a number, and the
|
||||
layout should not still be shaped around what it no longer holds. */
|
||||
.kpi {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface); padding: 16px;
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center;
|
||||
}
|
||||
.kpi-icon {
|
||||
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
|
||||
background: var(--color-brand-tint); color: var(--color-brand);
|
||||
}
|
||||
.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); }
|
||||
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
|
||||
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
|
||||
has stopped doing its job. The min-height keeps all five value rows level
|
||||
whether the label took one line or two. */
|
||||
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
|
||||
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
|
||||
/* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`,
|
||||
`.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the
|
||||
same object that `components/KpiCard.tsx` renders, with a different icon
|
||||
radius, a different value size and a different gap. Both appeared on the
|
||||
same screen. The component won, this strip now renders it, and the classes
|
||||
are gone rather than left as a near-copy for someone to edit by mistake. */
|
||||
|
||||
|
||||
/* ── Panels ─────────────────────────────────────────────────────────────── */
|
||||
/* ── Panels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
The Console's panels are the SAME object as `components/Panel.tsx` — a
|
||||
framed card with a header band, a body and a footer band. They stay as CSS
|
||||
classes rather than becoming that component because these six sections each
|
||||
lay their own body out (a chart, a donut, a map, a list of alerts) and have
|
||||
no table or pager to hand it; the component's job is the table case.
|
||||
|
||||
What matters is that they are the same DESIGN. They were not: this file had
|
||||
a white header with a 15.5px title and a sub-line, the component has a
|
||||
tinted band with an 11px eyebrow, and the two sat on the same screen. The
|
||||
band and the type below are now the component's, restated here.
|
||||
|
||||
The panel no longer pads itself — the bands and the body pad separately, or
|
||||
a header band cannot reach the panel's edges. */
|
||||
.panel {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface); padding: 18px;
|
||||
display: flex; flex-direction: column; gap: 14px; min-width: 0;
|
||||
border: var(--card-border); border-radius: var(--card-radius);
|
||||
background: var(--card-bg); box-shadow: var(--card-shadow);
|
||||
display: flex; flex-direction: column; min-width: 0;
|
||||
/* The clip belongs on the element that owns the radius, or the header
|
||||
band's tint squares off the top two corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The body. Every direct child of `.panel` that is not a band is content, so
|
||||
the padding and the stack live on a wrapper the sections already render. */
|
||||
.panel > :not(.panel-head):not(.panel-foot) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
/* Consecutive content blocks should not double their gutter. */
|
||||
.panel > :not(.panel-head):not(.panel-foot) ~ :not(.panel-head):not(.panel-foot) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 14px; flex-wrap: wrap;
|
||||
min-height: 48px; padding: 8px 18px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-bottom: var(--card-border);
|
||||
}
|
||||
/* An eyebrow, matching the table panels: 11px caps. A 15.5px heading inside a
|
||||
card competes with the page's own title above it. */
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font: 600 11px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
/* Title and sub-line sit on ONE row, the way the table panel's title and count
|
||||
do. Every section wraps the pair in a plain `div`, which stacks them by
|
||||
default — and a stacked pair is the two-line header this band replaced. */
|
||||
.panel-head > div:first-child {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-sub {
|
||||
margin: 0;
|
||||
font: 400 12px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
|
||||
.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); }
|
||||
.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||
.panel-foot {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3);
|
||||
padding: 8px 18px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-top: var(--card-border);
|
||||
font: 400 12px/1.5 var(--font-sans); color: var(--color-ink-3);
|
||||
}
|
||||
.panel-empty {
|
||||
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
|
||||
@@ -140,76 +184,282 @@
|
||||
.link-quiet { font: 500 12.5px/1 var(--font-sans); color: var(--color-brand); text-decoration: none; }
|
||||
.link-quiet:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Sales overview ─────────────────────────────────────────────────────── */
|
||||
.sales-split { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 720px) { .sales-split { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
|
||||
/* ── Sales overview ─────────────────────────────────────────────────────────
|
||||
|
||||
.chart-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; }
|
||||
.chart-axis {
|
||||
display: flex; flex-direction: column; justify-content: space-between; height: 190px;
|
||||
text-align: right; font: 400 10.5px/1 var(--font-sans); color: var(--color-ink-4);
|
||||
The chart's two colours are here, and they are measured rather than chosen.
|
||||
|
||||
The pair before them was the brand purple and the brand at 32% on white, and
|
||||
the palette checks failed three ways: #662582 sits at OKLCH L 0.406, below
|
||||
the 0.43 floor for a chart mark; the 32% mix came out at chroma 0.047, which
|
||||
reads as grey rather than as a colour; and it cleared 1.82:1 against white,
|
||||
under the 3:1 minimum. The second channel was a mark you could not reliably
|
||||
see.
|
||||
|
||||
These two clear every check on white with no warnings — lightness band,
|
||||
chroma floor, CVD separation (ΔE 27.4 worst adjacent under protanopia),
|
||||
normal-vision floor (29.4) and contrast. Purple + BLUE was the obvious
|
||||
alternative and it fails: ΔE 5.8 under deuteranopia, well under the 8 target.
|
||||
|
||||
--chart-online is a lighter step of the Nearle purple rather than the brand
|
||||
itself, because the brand is out of the band for a mark. It is used as this
|
||||
series and nowhere else.
|
||||
|
||||
Dark mode is not reachable today (main.tsx pins mode="light"), so only the
|
||||
light pair is validated. A dark theme needs its own steps and its own run of
|
||||
the checks, not an automatic flip. */
|
||||
:root {
|
||||
--chart-online: #8b3fb5;
|
||||
--chart-counter: #e0bdf0; /* Lighter tint of primary purple */
|
||||
}
|
||||
|
||||
.chart-block { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* ── The plot ───────────────────────────────────────────────────────────────
|
||||
Two stacked rows — plot and day labels — so the container's height INCLUDES
|
||||
the x-axis band. A fixed height around the plot alone is what gives a card
|
||||
its own little nested scrollbar. */
|
||||
.plot {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 200px auto;
|
||||
padding-left: 46px; /* the axis gutter the tick labels sit in */
|
||||
}
|
||||
|
||||
/* The grid is drawn under the marks: hairline, solid, one step off the
|
||||
surface. Never dashed — dashing reads as "threshold" when it is just a
|
||||
grid. */
|
||||
.plot-grid {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.plot-gridline {
|
||||
position: relative;
|
||||
border-top: 0;
|
||||
}
|
||||
.plot-tick {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: -0.55em;
|
||||
font: 400 10.5px/1 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
font-variant-numeric: tabular-nums; /* a column of numbers: tabular */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plot-cols {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plot-col {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
.plot-col:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
/* The stack. Capped at 24px — a column that fills its slot leaves no air, and
|
||||
the leftover band is what makes the hover target bigger than the mark. */
|
||||
.plot-stack {
|
||||
display: flex;
|
||||
flex-direction: column-reverse; /* counter at the base, online on top */
|
||||
width: 100%;
|
||||
max-width: 28px;
|
||||
min-height: 2px;
|
||||
gap: 0; /* removed surface gap between segments */
|
||||
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.plot-col[data-hover='true'] .plot-stack { opacity: 0.78; }
|
||||
|
||||
.plot-seg { min-height: 0; }
|
||||
/* 4px rounded data-end, square at the baseline: only the top of the stack is
|
||||
rounded, and only because it is the top. */
|
||||
.plot-stack > .plot-seg:last-child { border-radius: 4px 4px 0 0; }
|
||||
.plot-seg[data-kind='online'] { background: var(--chart-online); }
|
||||
.plot-seg[data-kind='counter'] { background: var(--chart-counter); }
|
||||
|
||||
/* One direct label, on the tallest column. Selective by design — a number on
|
||||
every column is chaos and goes unread. */
|
||||
.plot-peak {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -4px);
|
||||
font: 600 10.5px/1 var(--font-sans);
|
||||
color: var(--color-ink-2); /* a text token, never the series colour */
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Tooltip ────────────────────────────────────────────────────────────── */
|
||||
.plot-tip {
|
||||
position: absolute;
|
||||
/* Pinned to the top of the PLOT, not floated above the bar.
|
||||
Anchored to the bar it was clipped: the panel carries `overflow: hidden`
|
||||
so its header band cannot square off the card corners, and the tallest
|
||||
column's tooltip ran straight through the top edge and lost its first
|
||||
row. Hanging from the plot ceiling it is always inside the card, whatever
|
||||
the column height, and the hovered column is tinted so the pairing is
|
||||
still obvious. */
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 152px;
|
||||
padding: 9px 11px;
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
.plot-tip-day {
|
||||
font: 600 11.5px/1.2 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.plot-tip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font: 400 11.5px/1.3 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plot-tip-row b {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chart2 {
|
||||
display: flex; align-items: flex-end; gap: 10px; height: 190px; overflow-x: auto;
|
||||
border-left: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); padding: 0 6px;
|
||||
}
|
||||
.chart2-col { flex: 1 0 34px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; }
|
||||
.chart2-bars { display: flex; align-items: flex-end; gap: 3px; height: 100%; width: 100%; justify-content: center; }
|
||||
.chart2-bar { width: 11px; border-radius: 4px 4px 0 0; min-height: 2px; transition: opacity .15s ease; }
|
||||
.chart2-bar[data-kind="online"] { background: var(--color-brand); }
|
||||
/* The second channel is the same hue at lower weight, so the pair reads as one
|
||||
family rather than two unrelated categories. */
|
||||
.chart2-bar[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
|
||||
.chart2-col:hover .chart2-bar { opacity: .78; }
|
||||
.chart2-label { font: 500 10.5px/1 var(--font-sans); color: var(--color-ink-4); white-space: nowrap; }
|
||||
|
||||
.chart-legend { grid-column: 2; display: flex; gap: 16px; padding-top: 6px; }
|
||||
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; font: 400 12px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
|
||||
.chart-legend i[data-kind="online"] { background: var(--color-brand); }
|
||||
.chart-legend i[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
|
||||
|
||||
.donut-wrap { display: flex; align-items: center; justify-content: center; }
|
||||
.donut { position: relative; width: 160px; height: 160px; }
|
||||
.donut svg { width: 100%; height: 100%; }
|
||||
.donut-track { fill: none; stroke: var(--color-surface-subtle); stroke-width: 18; }
|
||||
.donut-fill { fill: none; stroke: var(--color-brand); stroke-width: 18; transition: stroke-dasharray .4s ease; }
|
||||
.donut-centre { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 2px; }
|
||||
.donut-centre span { font: 400 11px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||
.donut-centre strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.donut-key { width: 10px; height: 10px; border-radius: 999px; flex: none; }
|
||||
.donut-key[data-kind="online"] { background: var(--color-brand); }
|
||||
.donut-key[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
|
||||
|
||||
/* The channel split, on one line beneath the card. Two columns so the pair
|
||||
sits square against each other; stacked only when there is genuinely no room
|
||||
for both. */
|
||||
.split-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
.plot-tip-row[data-total='true'] {
|
||||
padding-top: 3px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
.plot-tip-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
|
||||
.plot-tip-row i[data-kind='online'] { background: var(--chart-online); }
|
||||
.plot-tip-row i[data-kind='counter'] { background: var(--chart-counter); }
|
||||
|
||||
/* Flip at the edges so the first and last day's tooltip stays on the card. */
|
||||
.plot-col:first-child .plot-tip { left: 0; transform: translate(0, 0); }
|
||||
.plot-col:last-child .plot-tip { left: auto; right: 0; transform: translate(0, 0); }
|
||||
|
||||
/* ── Day labels ─────────────────────────────────────────────────────────── */
|
||||
.plot-days {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.plot-day {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font: 400 10.5px/1.2 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Legend and the table toggle ────────────────────────────────────────── */
|
||||
.chart-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chart-legend { display: flex; gap: 16px; }
|
||||
.chart-legend span {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font: 400 12px/1 var(--font-sans); color: var(--color-ink-3);
|
||||
}
|
||||
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
|
||||
.chart-legend i[data-kind='online'] { background: var(--chart-online); }
|
||||
.chart-legend i[data-kind='counter'] { background: var(--chart-counter); }
|
||||
|
||||
.chart-toggle {
|
||||
border: 0; background: none; padding: 0; cursor: pointer;
|
||||
font: 600 12px/1 var(--font-sans); color: var(--color-brand);
|
||||
}
|
||||
.chart-toggle:hover { text-decoration: underline; }
|
||||
.chart-toggle:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
|
||||
|
||||
/* ── Table view ─────────────────────────────────────────────────────────── */
|
||||
.chart-table { max-height: 232px; overflow-y: auto; }
|
||||
.chart-table table { width: 100%; border-collapse: collapse; }
|
||||
.chart-table th {
|
||||
position: sticky; top: 0;
|
||||
text-align: left; padding: 7px 10px;
|
||||
background: var(--color-surface-subtle);
|
||||
font: 600 10.5px/1 var(--font-sans);
|
||||
letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-ink-3);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.chart-table td {
|
||||
padding: 8px 10px;
|
||||
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-1);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
|
||||
}
|
||||
.chart-table tr:last-child td { border-bottom: 0; }
|
||||
.chart-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── The channel split ──────────────────────────────────────────────────────
|
||||
One bar, then the two figures. This is what a 160px two-slice donut was
|
||||
doing, in the form that can be read rather than estimated. */
|
||||
.split {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
padding-top: 14px; border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.split-bar { display: flex; height: 10px; gap: 2px; }
|
||||
.split-bar span:first-child { border-radius: 999px 0 0 999px; }
|
||||
.split-bar span:last-child { border-radius: 0 999px 999px 0; }
|
||||
.split-bar span[data-kind='online'] { background: var(--chart-online); }
|
||||
.split-bar span[data-kind='counter'] { background: var(--chart-counter); }
|
||||
|
||||
.split-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
@media (max-width: 520px) { .split-row { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
.share {
|
||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-line); border-radius: 12px;
|
||||
background: var(--color-surface-subtle);
|
||||
border: var(--card-border); border-radius: var(--card-radius-sm);
|
||||
background: var(--card-bg-subtle);
|
||||
}
|
||||
.share .donut-key { align-self: center; }
|
||||
.share-key { width: 10px; height: 10px; border-radius: 999px; flex: none; align-self: center; }
|
||||
.share-key[data-kind='online'] { background: var(--chart-online); }
|
||||
.share-key[data-kind='counter'] { background: var(--chart-counter); }
|
||||
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||
/* The percentage leads: the question the split answers is "how much of my
|
||||
trade comes through each", and the rupee figure is already in the KPI card
|
||||
above. */
|
||||
above. Proportional figures, not tabular — this is a standalone value, not a
|
||||
column of them. */
|
||||
.share-pct { font: 600 18px/1 var(--font-sans); color: var(--color-ink-1); margin-left: auto; }
|
||||
.share-amt {
|
||||
font: 500 13px/1 var(--font-sans); color: var(--color-brand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
.share-amt { font: 500 13px/1 var(--font-sans); color: var(--color-ink-2); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.plot { padding-left: 40px; grid-template-rows: 170px auto; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.plot-stack { transition: none; }
|
||||
}
|
||||
|
||||
/* ── Store health ───────────────────────────────────────────────────────── */
|
||||
@@ -236,8 +486,8 @@
|
||||
.quick-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.quick {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
|
||||
padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px;
|
||||
text-decoration: none; background: var(--color-surface);
|
||||
padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
|
||||
text-decoration: none; background: var(--card-bg);
|
||||
}
|
||||
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||
@@ -265,31 +515,22 @@
|
||||
.heartbeat[data-tone="critical"] i { background: #b3261e; }
|
||||
|
||||
.till-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.chip-row { display: inline-flex; gap: 6px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border-radius: 999px; cursor: pointer; border: 1px solid var(--color-line);
|
||||
background: var(--color-surface); font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2);
|
||||
}
|
||||
.chip b { font-weight: 600; opacity: .7; }
|
||||
.chip[data-active="true"] { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
|
||||
.chip[data-tone="attention"][data-active="true"] { background: #8a5a00; border-color: #8a5a00; }
|
||||
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
|
||||
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
/* `.chip` and `.chip-row` were here — the filter strip on the Console’s Till
|
||||
sync panel and on Counters. Both render `components/TabBar` at `size="sm"`
|
||||
now, the same control as the Sales status ladder.
|
||||
|
||||
.search {
|
||||
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px;
|
||||
min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px;
|
||||
background: var(--color-surface); color: var(--color-ink-4);
|
||||
}
|
||||
.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); }
|
||||
.search:focus-within { border-color: var(--color-brand); }
|
||||
The tones went with them: green on Healthy, amber on Attention, red on
|
||||
Offline. They only ever applied to the SELECTED chip, so a strip at rest
|
||||
was plain pills anyway, and this console’s position is that it does not
|
||||
colour-code severity — see the note where `BUCKET_COLOR` used to be in
|
||||
`terminalProblems.ts`. The label and the count say which rung is bad. */
|
||||
|
||||
/* ── Segmented control ──────────────────────────────────────────────────── */
|
||||
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
|
||||
.seg-btn { border: 0; background: none; padding: 6px 14px; border-radius: 8px; font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2); cursor: pointer; }
|
||||
.seg-btn[data-active="yes"] { background: var(--color-brand); color: #fff; }
|
||||
.seg-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
|
||||
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
|
||||
input version of the search box. Both use `components/SearchInput` now. */
|
||||
|
||||
/* The `.seg` segmented control was here — an eighth treatment of "tab", used by
|
||||
the Sales overview's Revenue / Orders switch. That switch is
|
||||
`components/TabBar` at `size="sm"` now, like every other one in the console. */
|
||||
|
||||
/* ── Needs your attention ───────────────────────────────────────────────── */
|
||||
.attention-head { display: flex; gap: 12px; align-items: flex-start; }
|
||||
@@ -303,7 +544,7 @@
|
||||
|
||||
.attention-card {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
|
||||
align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px;
|
||||
align-items: center; padding: 13px 14px; border: var(--card-border); border-radius: var(--card-radius);
|
||||
}
|
||||
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
|
||||
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
|
||||
@@ -324,13 +565,15 @@
|
||||
/* ── Loading ────────────────────────────────────────────────────────────── */
|
||||
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
|
||||
.sk-card, .sk-chart {
|
||||
border: 1px solid var(--color-line); border-radius: 16px;
|
||||
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
|
||||
border: var(--card-border); border-radius: var(--card-radius);
|
||||
background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
.sk-card { height: 104px; }
|
||||
.sk-chart { height: 300px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sk-card, .sk-chart, .console-live i { animation: none; }
|
||||
.chart2-bar, .donut-fill { transition: none; }
|
||||
/* `.chart2-bar` and `.donut-fill` were named here. Both are gone — the chart
|
||||
is stacked columns and the split is a bar, not a ring — and the column's
|
||||
own transition is handled beside it, in the Sales overview block. */
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface BranchOverviewProps {
|
||||
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No branches are set up yet.
|
||||
</Text>
|
||||
@@ -42,7 +42,7 @@ export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<Card padding={0}>
|
||||
<div className="table-scroll">
|
||||
<table className="branch-table">
|
||||
<thead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { count, money } from '@/features/store-admin/format';
|
||||
import type { ConsoleTotals } from '../consoleModel';
|
||||
|
||||
@@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
|
||||
return (
|
||||
<div className="kpi-strip">
|
||||
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
||||
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
||||
<Kpi
|
||||
<KpiCard icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
|
||||
<KpiCard icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
|
||||
<KpiCard
|
||||
icon={<ListChecks size={17} />}
|
||||
label="Total Orders"
|
||||
value={count(totals.totalOrders)}
|
||||
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
|
||||
/>
|
||||
<Kpi
|
||||
<KpiCard
|
||||
icon={<Ban size={17} />}
|
||||
label="Cancelled Orders"
|
||||
value={count(totals.cancelled)}
|
||||
@@ -55,7 +55,7 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
|
||||
: {})}
|
||||
/>
|
||||
<Kpi
|
||||
<KpiCard
|
||||
icon={<CloudUpload size={17} />}
|
||||
label="Unsynced Bills"
|
||||
value={count(totals.pendingBills)}
|
||||
@@ -65,22 +65,3 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
icon, label, value, note,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
note?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="kpi">
|
||||
<span className="kpi-icon" aria-hidden>{icon}</span>
|
||||
<div className="kpi-head">
|
||||
<span className="kpi-label">{label}</span>
|
||||
<span className="kpi-value">{value}</span>
|
||||
{note ? <span className="kpi-note">{note}</span> : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { money, count } from '@/features/store-admin/format';
|
||||
import type { DaySeries } from '../useConsoleSeries';
|
||||
|
||||
/**
|
||||
* Revenue and orders over the period: the shape on the left, the split on the
|
||||
* right.
|
||||
* Revenue and orders over the period.
|
||||
*
|
||||
* Two views of one dataset, deliberately. The columns answer "when did it
|
||||
* happen"; the ring answers "where did it come from". Either alone leaves a
|
||||
* shopkeeper doing arithmetic in their head — a good week of counter sales and
|
||||
* a collapse in app orders look identical in a single total.
|
||||
* ── What this replaced, and why each piece went ─────────────────────────────
|
||||
*
|
||||
* Drawn by hand rather than with a charting library. It is a few dozen columns
|
||||
* and one ring; a library would be more code shipped than the chart it draws,
|
||||
* and this way the bars use the same brand token as everything else on the page
|
||||
* instead of a palette of its own.
|
||||
* **The donut is gone.** It was a ring showing one share — online against
|
||||
* counter — with the same two figures spelled out in a row directly beneath
|
||||
* it. A two-slice ring is the textbook case for "the number is the chart": it
|
||||
* spends 160×160px to say what "68% / 32%" says in nine characters, and it says
|
||||
* it less precisely. The share is now a single slim bar above the two figures,
|
||||
* which is the form for part-to-whole and doubles as the legend key.
|
||||
*
|
||||
* **The columns are stacked, not grouped.** Two thin bars per day meant 60
|
||||
* marks across a 30-day range at ~11px each. Stacked, a column's height is the
|
||||
* day's takings — the thing a shopkeeper actually reads off this chart — and
|
||||
* the split is still visible inside it.
|
||||
*
|
||||
* **The colours are measured, not chosen.** The old pair was the brand purple
|
||||
* and the brand at 32% on white, and running them through the palette checks
|
||||
* failed three ways: the brand sits at OKLCH L 0.406, below the 0.43 floor for
|
||||
* a chart mark; the 32% mix landed at chroma 0.047, which reads as grey rather
|
||||
* than as a colour; and it cleared only 1.82:1 against white, under the 3:1
|
||||
* minimum. So the second channel was a mark you could not reliably see. The
|
||||
* pair below is validated — see `--chart-online` in `console.css`.
|
||||
*
|
||||
* **There is a real tooltip.** Values used to live in `title` attributes: a
|
||||
* browser tooltip after a second's delay, unstyled, invisible on touch. And a
|
||||
* table view, because with a stacked column the per-channel figure for one day
|
||||
* exists nowhere else on the page — a tooltip must enhance, never gate.
|
||||
*/
|
||||
export interface SalesOverviewProps {
|
||||
days: readonly DaySeries[];
|
||||
@@ -25,6 +42,8 @@ type Measure = 'revenue' | 'orders';
|
||||
|
||||
export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
const [measure, setMeasure] = useState<Measure>('revenue');
|
||||
const [isTable, setIsTable] = useState(false);
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
const series = useMemo(
|
||||
() =>
|
||||
@@ -36,16 +55,41 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
[days, measure],
|
||||
);
|
||||
|
||||
/* The axis tops out at a round number at or above the tallest column, so the
|
||||
ticks read 0 / 5,000 / 10,000 rather than 0 / 4,317 / 8,634. */
|
||||
const peak = useMemo(
|
||||
() => Math.max(1, ...series.map((d) => Math.max(d.online, d.counter))),
|
||||
() => Math.max(1, ...series.map((d) => d.online + d.counter)),
|
||||
[series],
|
||||
);
|
||||
const axisTop = useMemo(() => niceCeiling(peak), [peak]);
|
||||
const ticks = useMemo(
|
||||
() => [1, 0.75, 0.5, 0.25, 0].map((step) => Math.round(axisTop * step)),
|
||||
[axisTop],
|
||||
);
|
||||
|
||||
/* Labelled directly, because it is the one column worth naming. Everything
|
||||
else is carried by the axis, the tooltip and the table. */
|
||||
const peakIndex = useMemo(() => {
|
||||
let best = -1;
|
||||
let bestTotal = 0;
|
||||
series.forEach((d, i) => {
|
||||
const total = d.online + d.counter;
|
||||
if (total > bestTotal) {
|
||||
bestTotal = total;
|
||||
best = i;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}, [series]);
|
||||
|
||||
const online = series.reduce((sum, d) => sum + d.online, 0);
|
||||
const counter = series.reduce((sum, d) => sum + d.counter, 0);
|
||||
const total = online + counter;
|
||||
const onlineShare = total > 0 ? Math.round((online / total) * 100) : 0;
|
||||
|
||||
const format = (value: number) => (measure === 'revenue' ? money(value) : count(value));
|
||||
const axisFormat = (value: number) =>
|
||||
measure === 'revenue' ? compactMoney(value) : count(value);
|
||||
|
||||
if (isLoading) return <div className="sk-chart" aria-busy="true" />;
|
||||
|
||||
@@ -62,21 +106,14 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
: `Revenue and orders for the last ${days.length} days`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="seg" role="tablist" aria-label="Measure">
|
||||
{(['revenue', 'orders'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={measure === m}
|
||||
className="seg-btn"
|
||||
data-active={measure === m ? 'yes' : 'no'}
|
||||
onClick={() => setMeasure(m)}
|
||||
>
|
||||
{m === 'revenue' ? 'Revenue' : 'Orders'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TabBar size="sm" aria-label="Measure">
|
||||
<Tab
|
||||
label="Revenue"
|
||||
isActive={measure === 'revenue'}
|
||||
onClick={() => setMeasure('revenue')}
|
||||
/>
|
||||
<Tab label="Orders" isActive={measure === 'orders'} onClick={() => setMeasure('orders')} />
|
||||
</TabBar>
|
||||
</header>
|
||||
|
||||
{total === 0 ? (
|
||||
@@ -85,83 +122,146 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
|
||||
<span>Pick a wider date range, or check back once the shop has taken an order.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sales-split">
|
||||
<div className="chart-wrap">
|
||||
{/* The axis is drawn from the data rather than fixed, so a shop
|
||||
taking ₹800 a day gets a readable scale instead of a flat line
|
||||
along the bottom of a ₹20K grid. */}
|
||||
<div className="chart-axis" aria-hidden>
|
||||
{[1, 0.75, 0.5, 0.25, 0].map((step) => (
|
||||
<span key={step}>{format(Math.round(peak * step))}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chart2">
|
||||
{series.map((d) => (
|
||||
<div key={d.day} className="chart2-col">
|
||||
<div className="chart2-bars">
|
||||
<span
|
||||
className="chart2-bar"
|
||||
data-kind="online"
|
||||
style={{ height: `${(d.online / peak) * 100}%` }}
|
||||
title={`${shortDay(d.day)} · Online ${format(d.online)}`}
|
||||
/>
|
||||
<span
|
||||
className="chart2-bar"
|
||||
data-kind="counter"
|
||||
style={{ height: `${(d.counter / peak) * 100}%` }}
|
||||
title={`${shortDay(d.day)} · Counter ${format(d.counter)}`}
|
||||
/>
|
||||
<div className="chart-block">
|
||||
{isTable ? (
|
||||
<SeriesTable rows={series} format={format} />
|
||||
) : (
|
||||
<div
|
||||
className="plot"
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
role="img"
|
||||
aria-label={`${measure === 'revenue' ? 'Revenue' : 'Orders'} by day, online and counter`}
|
||||
>
|
||||
{/* The grid is drawn first and sits under the marks: hairline,
|
||||
solid, one step off the surface. Its rows and the axis labels
|
||||
come from the same tick list, so they cannot drift apart. */}
|
||||
<div className="plot-grid" aria-hidden>
|
||||
{ticks.map((tick) => (
|
||||
<div key={tick} className="plot-gridline">
|
||||
<span className="plot-tick">{axisFormat(tick)}</span>
|
||||
</div>
|
||||
<span className="chart2-label">{shortDay(d.day)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chart-legend">
|
||||
<span><i data-kind="online" />Online</span>
|
||||
<span><i data-kind="counter" />Counter</span>
|
||||
<div className="plot-cols">
|
||||
{series.map((d, index) => {
|
||||
const dayTotal = d.online + d.counter;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="plot-col"
|
||||
data-hover={hovered === index}
|
||||
onMouseEnter={() => setHovered(index)}
|
||||
onFocus={() => setHovered(index)}
|
||||
onBlur={() => setHovered(null)}
|
||||
tabIndex={0}
|
||||
aria-label={`${longDay(d.day)}: online ${format(d.online)}, counter ${format(d.counter)}`}
|
||||
>
|
||||
{/* The stack. Counter sits under online so the brand
|
||||
colour caps the column, and the two are separated by a
|
||||
gap in the surface rather than by a stroke. */}
|
||||
<div className="plot-stack" style={{ height: `${(dayTotal / axisTop) * 100}%` }}>
|
||||
<span
|
||||
className="plot-seg"
|
||||
data-kind="online"
|
||||
style={{ flexBasis: `${pct(d.online, dayTotal)}%` }}
|
||||
/>
|
||||
<span
|
||||
className="plot-seg"
|
||||
data-kind="counter"
|
||||
style={{ flexBasis: `${pct(d.counter, dayTotal)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{index === peakIndex ? (
|
||||
<span className="plot-peak" style={{ bottom: `${(dayTotal / axisTop) * 100}%` }}>
|
||||
{format(dayTotal)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{hovered === index ? (
|
||||
<div className="plot-tip" role="status">
|
||||
<span className="plot-tip-day">{longDay(d.day)}</span>
|
||||
<span className="plot-tip-row">
|
||||
<i data-kind="online" />
|
||||
Online
|
||||
<b>{format(d.online)}</b>
|
||||
</span>
|
||||
<span className="plot-tip-row">
|
||||
<i data-kind="counter" />
|
||||
Counter
|
||||
<b>{format(d.counter)}</b>
|
||||
</span>
|
||||
<span className="plot-tip-row" data-total="true">
|
||||
Total<b>{format(dayTotal)}</b>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Every date is drawn — see `showLabel`. */}
|
||||
<div className="plot-days" aria-hidden>
|
||||
{series.map((d) => (
|
||||
<span key={d.day} className="plot-day">
|
||||
{showLabel() ? shortDay(d.day) : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chart-foot">
|
||||
<div className="chart-legend">
|
||||
<span>
|
||||
<i data-kind="online" />
|
||||
Online
|
||||
</span>
|
||||
<span>
|
||||
<i data-kind="counter" />
|
||||
Counter
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="chart-toggle" onClick={() => setIsTable((v) => !v)}>
|
||||
{isTable ? 'Show chart' : 'Show table'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="donut-wrap">
|
||||
<Donut share={onlineShare} total={format(total)} />
|
||||
{/* The channel split: one bar, then the figures. This is what the
|
||||
donut was for, in a form that can be read rather than estimated. */}
|
||||
<div className="split">
|
||||
<div className="split-row">
|
||||
<Share kind="online" label="Online" percent={onlineShare} amount={format(online)} />
|
||||
<Share
|
||||
kind="counter"
|
||||
label="Counter"
|
||||
percent={100 - onlineShare}
|
||||
amount={format(counter)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The channel split, on one line under the whole card.
|
||||
It was a stacked column beside the ring, which put the two figures a
|
||||
shopkeeper compares — online against counter — one above the other in
|
||||
a narrow gutter. Side by side they can be read against each other at a
|
||||
glance, and the row runs the full width rather than being squeezed
|
||||
into whatever the ring left over. */}
|
||||
{total > 0 ? (
|
||||
<div className="split-row">
|
||||
<Share
|
||||
kind="online"
|
||||
label="Online Sales"
|
||||
percent={onlineShare}
|
||||
amount={format(online)}
|
||||
/>
|
||||
<Share
|
||||
kind="counter"
|
||||
label="Counter Sales"
|
||||
percent={100 - onlineShare}
|
||||
amount={format(counter)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Share({
|
||||
kind, label, percent, amount,
|
||||
}: { kind: 'online' | 'counter'; label: string; percent: number; amount: string }) {
|
||||
kind,
|
||||
label,
|
||||
percent,
|
||||
amount,
|
||||
}: {
|
||||
kind: 'online' | 'counter';
|
||||
label: string;
|
||||
percent: number;
|
||||
amount: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="share">
|
||||
<span className="donut-key" data-kind={kind} />
|
||||
<span className="share-key" data-kind={kind} />
|
||||
<span className="share-label">{label}</span>
|
||||
<strong className="share-pct">{percent}%</strong>
|
||||
<span className="share-amt">{amount}</span>
|
||||
@@ -170,42 +270,101 @@ function Share({
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel split as a ring.
|
||||
* The same numbers as a table.
|
||||
*
|
||||
* One circle with a dash offset rather than two arcs: a single stroke cannot
|
||||
* develop a seam at the join, and the whole thing animates from one number.
|
||||
* Not a nicety: in a stacked column the per-channel figure for a single day is
|
||||
* otherwise only in the hover tooltip, which is no use on a touch screen, to a
|
||||
* screen reader, or to anyone printing the page.
|
||||
*/
|
||||
function Donut({ share, total }: { share: number; total: string }) {
|
||||
const radius = 54;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const filled = (share / 100) * circumference;
|
||||
|
||||
function SeriesTable({
|
||||
rows,
|
||||
format,
|
||||
}: {
|
||||
rows: readonly { day: string; online: number; counter: number }[];
|
||||
format: (value: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="donut">
|
||||
<svg viewBox="0 0 140 140" role="img" aria-label={`Online sales ${share}% of the total`}>
|
||||
<circle cx="70" cy="70" r={radius} className="donut-track" />
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r={radius}
|
||||
className="donut-fill"
|
||||
strokeDasharray={`${filled} ${circumference - filled}`}
|
||||
// Start at twelve o'clock; the default is three, which reads as a
|
||||
// slice that has already been eaten.
|
||||
transform="rotate(-90 70 70)"
|
||||
/>
|
||||
</svg>
|
||||
<div className="donut-centre">
|
||||
<span>Total Sales</span>
|
||||
<strong>{total}</strong>
|
||||
</div>
|
||||
<div className="chart-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Day</th>
|
||||
<th className="num">Online</th>
|
||||
<th className="num">Counter</th>
|
||||
<th className="num">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((d) => (
|
||||
<tr key={d.day}>
|
||||
<td>{longDay(d.day)}</td>
|
||||
<td className="num">{format(d.online)}</td>
|
||||
<td className="num">{format(d.counter)}</td>
|
||||
<td className="num">
|
||||
<strong>{format(d.online + d.counter)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
|
||||
/**
|
||||
* The next round number at or above `value` — 1, 2 or 5 times a power of ten.
|
||||
*
|
||||
* An axis whose top is the tallest column produces ticks like 4,317 and 8,634,
|
||||
* which nobody can read a bar against. This is what makes them 5,000 and
|
||||
* 10,000.
|
||||
*/
|
||||
function niceCeiling(value: number): number {
|
||||
if (value <= 0) return 1;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
const normalised = value / magnitude;
|
||||
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
|
||||
return step * magnitude;
|
||||
}
|
||||
|
||||
/** "₹12.4K" for an axis tick, where the full figure would not fit the gutter. */
|
||||
function compactMoney(value: number): string {
|
||||
if (value >= 10_000_000) return `₹${(value / 10_000_000).toFixed(1).replace(/\.0$/, '')}Cr`;
|
||||
if (value >= 100_000) return `₹${(value / 100_000).toFixed(1).replace(/\.0$/, '')}L`;
|
||||
if (value >= 1_000) return `₹${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
|
||||
return money(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which x labels to draw.
|
||||
*
|
||||
* Currently: all of them. The thinning this used to do — keep about six,
|
||||
* always including the first and the last — is gone by choice, so every date
|
||||
* is drawn.
|
||||
*
|
||||
* It keeps its own function rather than being inlined at the call site, so the
|
||||
* decision has somewhere to live and thinning is one edit away if a 30-day
|
||||
* range turns out to overlap. It takes NO arguments: it was passed the index
|
||||
* and the length, and with the body returning a constant those became unused
|
||||
* parameters, which `noUnusedParameters` fails the build on.
|
||||
*/
|
||||
function showLabel(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "2026-09-03" → "Sep 3". Falls back to the raw value rather than throwing. */
|
||||
function shortDay(day: string): string {
|
||||
const parsed = new Date(day);
|
||||
if (Number.isNaN(parsed.getTime())) return day;
|
||||
return parsed.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** "2026-09-03" → "Wed, 3 Sep" — the tooltip and table have room for the day. */
|
||||
function longDay(day: string): string {
|
||||
const parsed = new Date(day);
|
||||
if (Number.isNaN(parsed.getTime())) return day;
|
||||
return parsed.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Boxes, PackageSearch, Store } from 'lucide-react';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { count, money } from '@/features/store-admin/format';
|
||||
import { money, plural } from '@/features/store-admin/format';
|
||||
import type { BranchRow } from '../consoleModel';
|
||||
|
||||
/**
|
||||
@@ -29,7 +29,7 @@ export interface ShopSummaryProps {
|
||||
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
||||
if (!row) {
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
No branch is selected.
|
||||
</Text>
|
||||
@@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
||||
const total = row.onlineRevenue + row.counterRevenue;
|
||||
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} align="start" wrap="wrap">
|
||||
<span className="shop-mark" aria-hidden>
|
||||
@@ -78,8 +78,8 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
|
||||
</HStack>
|
||||
|
||||
<div className="shop-figures">
|
||||
<Figure label="Online" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
|
||||
<Figure label="Counter" value={money(row.counterRevenue)} note={`${count(row.counterBills)} bills`} />
|
||||
<Figure label="Online" value={money(row.onlineRevenue)} note={`${plural(row.onlineOrders, 'order')}`} />
|
||||
<Figure label="Counter" value={money(row.counterRevenue)} note={`${plural(row.counterBills, 'bill')}`} />
|
||||
<Figure label="Total" value={money(total)} note="this period" isStrong />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react';
|
||||
import { count } from '@/features/store-admin/format';
|
||||
import { count, plural } from '@/features/store-admin/format';
|
||||
import type { BranchRow, ConsoleTotals, HealthTone } from '../consoleModel';
|
||||
|
||||
/**
|
||||
@@ -96,7 +96,7 @@ export function StoreHealth({
|
||||
detail={
|
||||
productCount === 0
|
||||
? 'No products yet, so shoppers find the shop empty'
|
||||
: `${count(productCount)} products`
|
||||
: `${plural(productCount, 'product')}`
|
||||
}
|
||||
tone={productCount === 0 ? 'attention' : withoutTill > 0 ? 'attention' : 'healthy'}
|
||||
action="Manage"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { SearchInput } from '@/components/SearchInput';
|
||||
import { branchLabel, count, money } from '@/features/store-admin/format';
|
||||
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
|
||||
import type { BranchRow, HealthTone } from '../consoleModel';
|
||||
@@ -99,27 +100,34 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
|
||||
</div>
|
||||
|
||||
<div className="till-controls">
|
||||
<div className="chip-row">
|
||||
<button type="button" className="chip" data-active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
All <b>{tills.length}</b>
|
||||
</button>
|
||||
<button type="button" className="chip" data-tone="attention" data-active={filter === 'attention'} onClick={() => setFilter('attention')}>
|
||||
Attention <b>{needing}</b>
|
||||
</button>
|
||||
<button type="button" className="chip" data-tone="healthy" data-active={filter === 'healthy'} onClick={() => setFilter('healthy')}>
|
||||
Healthy <b>{healthy}</b>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="search">
|
||||
<Search size={14} aria-hidden />
|
||||
<input
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={showBranch ? 'Search till ID, branch…' : 'Search till ID…'}
|
||||
aria-label="Search tills"
|
||||
<TabBar size="sm" aria-label="Filter tills">
|
||||
<Tab
|
||||
label="All"
|
||||
count={tills.length}
|
||||
isActive={filter === 'all'}
|
||||
onClick={() => setFilter('all')}
|
||||
/>
|
||||
</label>
|
||||
<Tab
|
||||
label="Attention"
|
||||
count={needing}
|
||||
isActive={filter === 'attention'}
|
||||
onClick={() => setFilter('attention')}
|
||||
/>
|
||||
<Tab
|
||||
label="Healthy"
|
||||
count={healthy}
|
||||
isActive={filter === 'healthy'}
|
||||
onClick={() => setFilter('healthy')}
|
||||
/>
|
||||
</TabBar>
|
||||
|
||||
<SearchInput
|
||||
label="Search tills"
|
||||
value={term}
|
||||
onChange={setTerm}
|
||||
placeholder={showBranch ? 'Till ID, branch…' : 'Till ID…'}
|
||||
width={220}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { useOrders } from '@/queries/hooks';
|
||||
import type { PosSalesSummary } from '@/api/types';
|
||||
import { isCounterSale } from '@/features/store-admin/branchStats';
|
||||
|
||||
/**
|
||||
* The day-by-day series behind the chart and the donut.
|
||||
@@ -63,6 +64,20 @@ export function useConsoleSeries(
|
||||
const amount = amountOf(order);
|
||||
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
|
||||
|
||||
// A counter bill imported from a till sheet is in `orders` like any
|
||||
// other row, tagged OFFLINE by the backend, and counting it here put
|
||||
// the shop's counter takings on the app line of this chart and in the
|
||||
// "Online Sales" figure above it. The POS loop below adds the tills
|
||||
// that synced; these are the same money arriving the other way.
|
||||
if (isCounterSale(order)) {
|
||||
if (day && !isCancelled) {
|
||||
const row = at(day);
|
||||
row.counter += amount;
|
||||
row.counterBills += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
onlineOrders += 1;
|
||||
if (isCancelled) cancelled += 1;
|
||||
else onlineRevenue += amount;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
|
||||
import { DateScopeProvider } from '@/components/shell/DateScope';
|
||||
|
||||
/**
|
||||
* Nearle Admin — the platform workspace.
|
||||
*
|
||||
* No "Onboard branch" here on purpose. Opening an outlet is the merchant's
|
||||
* decision about their own business, so it sits in the Store Admin workspace.
|
||||
* We onboard the tenant; they onboard their branches.
|
||||
*/
|
||||
const NAV: readonly NavEntry[] = [
|
||||
{ to: '/nearle/stores', label: 'Stores' },
|
||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||
{ to: '/nearle/partners', label: 'Rider partners' },
|
||||
{ to: '/nearle/uploads', label: 'Uploads' },
|
||||
];
|
||||
|
||||
export function NearleAdminShell() {
|
||||
return (
|
||||
<DateScopeProvider>
|
||||
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />
|
||||
</DateScopeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Check, Truck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import { useAllPartners, useAppRegions } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||
|
||||
/**
|
||||
* Which delivery partner supplies a merchant's riders.
|
||||
*
|
||||
* ── Why this is the platform's decision ─────────────────────────────────────
|
||||
*
|
||||
* `partnerid` is kept out of the merchant-editable allowlist on the server, so
|
||||
* this cannot be done from the shop's own profile — a merchant who could set it
|
||||
* would move themselves under another partner's riders and billing. It is
|
||||
* changed here, by whoever is looking at that merchant's record.
|
||||
*
|
||||
* ── Why "own riders" is an option and not an absence ────────────────────────
|
||||
*
|
||||
* Sending `partnerid: 0` is a real instruction: it means the shop delivers with
|
||||
* riders it hired itself. The server reads a zero as sent rather than as a
|
||||
* missing field, which is exactly why the endpoint is separate — everywhere
|
||||
* else in the tenant API a zero means "not supplied", and there it would
|
||||
* silently unassign somebody.
|
||||
*
|
||||
* One partner per merchant, which is what `tenants.partnerid` allows and what
|
||||
* the assign picker later branches on. A partner serving several merchants is
|
||||
* the ordinary case in the other direction: 44 supplies 48 shops.
|
||||
*/
|
||||
export function PartnerAssignDrawer({
|
||||
tenantid,
|
||||
tenantname,
|
||||
currentPartnerId,
|
||||
onClose,
|
||||
}: {
|
||||
tenantid: number;
|
||||
tenantname: string;
|
||||
currentPartnerId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const partners = useAllPartners();
|
||||
const regions = useAppRegions();
|
||||
const [chosen, setChosen] = useState<number>(currentPartnerId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const regionName = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const region of regions.data ?? []) {
|
||||
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
|
||||
}
|
||||
return map;
|
||||
}, [regions.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => tenantsApi.assignPartner(tenantid, chosen),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
onClose();
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const current = partners.data.find((p) => p.partnerid === currentPartnerId);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Delivery partner"
|
||||
subtitle={tenantname}
|
||||
width={460}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label={save.isPending ? 'Saving…' : 'Save'}
|
||||
variant="primary"
|
||||
icon={<Truck size={15} />}
|
||||
isDisabled={save.isPending || chosen === currentPartnerId}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
{error ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Note icon={<Bike size={15} />}>
|
||||
A partner supplies riders to this shop. With one set, the assign screen offers the
|
||||
partner’s riders alongside any the shop hired itself; without one, only its own.
|
||||
</Note>
|
||||
|
||||
<Section title="Currently">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Partner"
|
||||
value={current?.partnername ?? (currentPartnerId > 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
|
||||
/>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
<Section title="Change to">
|
||||
<DrawerCard>
|
||||
{/* "Own riders" first and always present. It is not the empty state
|
||||
— a shop that hires its own riders is a real arrangement, and
|
||||
the option has to be as reachable as any partner. */}
|
||||
<Choice
|
||||
label="Own riders only"
|
||||
detail="This shop delivers with riders it hired itself."
|
||||
isChosen={chosen === 0}
|
||||
onChoose={() => setChosen(0)}
|
||||
/>
|
||||
{partners.isLoading ? (
|
||||
<Row label="Partners" value="Reading…" />
|
||||
) : (
|
||||
partners.data.map((partner) => (
|
||||
<Choice
|
||||
key={partner.partnerid}
|
||||
label={partner.partnername ?? `Partner ${partner.partnerid}`}
|
||||
detail={
|
||||
[
|
||||
regionName.get(partner.applocationid ?? 0),
|
||||
partner.companyname,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'No region recorded'
|
||||
}
|
||||
isChosen={chosen === partner.partnerid}
|
||||
onChoose={() => setChosen(partner.partnerid)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
label,
|
||||
detail,
|
||||
isChosen,
|
||||
onChoose,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
isChosen: boolean;
|
||||
onChoose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChoose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
border: 0,
|
||||
borderTop: '1px solid var(--color-line)',
|
||||
background: isChosen ? 'var(--color-brand-tint)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
flex: 'none',
|
||||
borderRadius: 999,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: isChosen ? 0 : '1.5px solid var(--color-line)',
|
||||
background: isChosen ? 'var(--color-brand)' : 'transparent',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{isChosen ? <Check size={11} strokeWidth={3} /> : null}
|
||||
</span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
font: '500 13.5px/1.4 var(--font-sans)',
|
||||
color: isChosen ? 'var(--color-brand)' : 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ font: '400 12px/1.4 var(--font-sans)', color: 'var(--color-ink-3)' }}>
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Plus } from 'lucide-react';
|
||||
import type { Partner, RiderRosterRow } from '@/api/deliveries';
|
||||
import { usePartnerRiders } from '@/queries/hooks';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
|
||||
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
|
||||
|
||||
/**
|
||||
* A delivery partner's riders, kept by the platform.
|
||||
*
|
||||
* ── Why the platform keeps them ─────────────────────────────────────────────
|
||||
*
|
||||
* A partner has no console of its own. Their riders serve whichever merchants
|
||||
* the partner supplies — one partner covers 48 shops today, another 63 — so
|
||||
* they sit under no single merchant and no merchant's console can manage them.
|
||||
* That leaves here.
|
||||
*
|
||||
* ── Duty is a state, never a filter ─────────────────────────────────────────
|
||||
*
|
||||
* This reads the ROSTER, not `getriders`. The second wants a clock-in stamped
|
||||
* today, so a rider hired five minutes ago is absent from it — which is exactly
|
||||
* what a failed save looks like. Everybody is listed, and whether they are
|
||||
* working right now is shown beside them.
|
||||
*/
|
||||
export function PartnerRidersDrawer({
|
||||
partner,
|
||||
onClose,
|
||||
}: {
|
||||
partner: Partner;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const riders = usePartnerRiders(partner.partnerid);
|
||||
const [editing, setEditing] = useState<RiderRosterRow | 'new' | null>(null);
|
||||
|
||||
const rows = riders.data ?? [];
|
||||
const onDuty = rows.filter((rider) => rider.isonduty).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title={partner.partnername ?? `Partner ${partner.partnerid}`}
|
||||
subtitle="Riders"
|
||||
width={520}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Close" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label="Add rider"
|
||||
variant="primary"
|
||||
icon={<Plus size={15} />}
|
||||
onClick={() => setEditing('new')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Note icon={<Bike size={15} />}>
|
||||
These riders deliver for every merchant this partner supplies. A rider hired here does
|
||||
not appear in the on-duty fleet until they open the rider app and start a shift — that
|
||||
is correct, and it looks exactly like a failed save.
|
||||
</Note>
|
||||
|
||||
{riders.isLoading ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading riders…
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Section title="No riders yet">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Fleet"
|
||||
value="This partner has nobody on their books. Add one to get started."
|
||||
isStacked
|
||||
/>
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : (
|
||||
<Section title={`${rows.length} rider${rows.length === 1 ? '' : 's'} · ${onDuty} on duty`}>
|
||||
<DrawerCard>
|
||||
{rows.map((rider) => (
|
||||
<Row
|
||||
key={rider.userid}
|
||||
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
|
||||
value={
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Badge
|
||||
label={rider.isonduty ? 'On duty' : 'Off shift'}
|
||||
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'}
|
||||
/>
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(rider)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
)}
|
||||
</VStack>
|
||||
</Drawer>
|
||||
|
||||
{editing ? (
|
||||
<RiderDrawer
|
||||
row={editing === 'new' ? null : editing}
|
||||
/* A partner's rider, so no tenant and no branch — they work a region
|
||||
and serve whoever the partner supplies in it. */
|
||||
owner={{
|
||||
kind: 'partner',
|
||||
partnerid: partner.partnerid,
|
||||
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
|
||||
applocationid: partner.applocationid ?? 0,
|
||||
}}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PackageSearch, Upload } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
|
||||
/**
|
||||
* The platform operator's catalogue.
|
||||
*
|
||||
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
|
||||
*
|
||||
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
|
||||
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
|
||||
* where — and nothing is written. There is no merchant to choose because
|
||||
* nothing lands anywhere, so the page is the rail, the search and the grid
|
||||
* and nothing else. The per-product Add is gone from here: stocking one
|
||||
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
|
||||
* Catalogue.
|
||||
*
|
||||
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
|
||||
* merchant's shelf. The ingest service parses, enriches and stores rows in
|
||||
* the per-brand tables every merchant reads from, and it takes no tenant and
|
||||
* no outlet. This mode therefore asks for nothing but a file.
|
||||
*
|
||||
* The grid, the filters, the cards and the detail drawer are the shared
|
||||
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
|
||||
* same catalogue.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
/*
|
||||
The tab is in the URL so it can be linked to.
|
||||
|
||||
Uploads has an "Upload spreadsheet" button and this is where that action
|
||||
lives for a platform operator — it writes the GLOBAL catalogue, which is not
|
||||
the same action as a merchant uploading their own list. Held in local state it
|
||||
could only be reached by landing on Browse and pressing a second control,
|
||||
which is a detour rather than a flow.
|
||||
*/
|
||||
const [params, setParams] = useSearchParams();
|
||||
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
|
||||
const setMode = (next: 'catalogue' | 'sheet') => {
|
||||
const merged = new URLSearchParams(params);
|
||||
if (next === 'catalogue') merged.delete('tab');
|
||||
else merged.set('tab', next);
|
||||
setParams(merged, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
actions={
|
||||
<SegmentedControl
|
||||
label="What to do"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
|
||||
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
|
||||
</SegmentedControl>
|
||||
}
|
||||
/>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
/* The merchant and outlet pickers used to sit above this panel, and
|
||||
they described a flow that no longer exists.
|
||||
|
||||
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
|
||||
a tenant or an outlet — putting a product on one shop's shelf with a
|
||||
price and opening stock is a separate call (`/api/upload/stores`,
|
||||
joined on `image_id`) that is not wired up yet. Two selectors saying
|
||||
the upload was "written against one merchant and one outlet" would
|
||||
have had someone pick a shop, upload, and then go looking for stock
|
||||
that was never going to arrive.
|
||||
|
||||
They come back with the inventory step, and mean something then. */
|
||||
<SheetImportPanel />
|
||||
) : (
|
||||
<CatalogueBrowser
|
||||
tenantid={undefined}
|
||||
locationid={undefined}
|
||||
actionLabel="Add to store"
|
||||
isReadOnly
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
|
||||
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||
import { useAppCategories } from '@/queries/hooks';
|
||||
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
/** Who runs the shop. Written to `tenants.firstname` — see `CreateTenantRequest`. */
|
||||
adminname: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
locationname: string;
|
||||
categoryid: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
tenantname: '',
|
||||
companyname: '',
|
||||
adminname: '',
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
// No default category. `utils/getappcategories` supplies the real list and
|
||||
// this used to pre-select the first id regardless of what the merchant sells.
|
||||
categoryid: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
// Empty, not pre-filled.
|
||||
//
|
||||
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
|
||||
// so a merchant anywhere else was submitted with the wrong city and state
|
||||
// unless somebody noticed and cleared them. A default that is right most of
|
||||
// the time is worse than a blank field, because it is only checked when it
|
||||
// looks wrong.
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provision a new merchant tenant.
|
||||
*
|
||||
* This registers the enterprise, its first outlet, and spawns the primary
|
||||
* Administrator account — account creation is a side effect of provisioning
|
||||
* here, because that is what the backend does. There is no separate invite.
|
||||
*
|
||||
* Products are NOT collected on this form. Once the tenant exists it has a
|
||||
* tenantid and a locationid, and only then can either import path run: both
|
||||
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
|
||||
* form hands off to the catalogue on success rather than pretending the two
|
||||
* steps are one.
|
||||
*/
|
||||
export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
/**
|
||||
* The category list comes from `app_category`, not from four values typed
|
||||
* into this file. A category added to the master should appear here without
|
||||
* a release.
|
||||
*/
|
||||
const categories = useAppCategories();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.tenantname.trim() !== '' &&
|
||||
form.companyname.trim() !== '' &&
|
||||
form.adminname.trim() !== '' &&
|
||||
form.primarycontact.trim() !== '' &&
|
||||
form.primaryemail.trim() !== '' &&
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.state.trim() !== '' &&
|
||||
form.categoryid !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate({
|
||||
tenantname: form.tenantname.trim(),
|
||||
companyname: form.companyname.trim(),
|
||||
firstname: form.adminname.trim(),
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
// `Number('')` is NaN, which serialises to null and is not what the
|
||||
// backend means by "uncategorised" — 0 is.
|
||||
categoryid: Number(form.categoryid) || 0,
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
postcode: form.postcode.trim(),
|
||||
status: 'Active',
|
||||
});
|
||||
}
|
||||
|
||||
if (mutation.isSuccess) {
|
||||
const created = mutation.data;
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Tenant provisioned"
|
||||
/>
|
||||
<Card padding={4} elevation="low">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="large" weight="semibold">
|
||||
{form.tenantname} is live
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" color="secondary">
|
||||
Its first outlet, {form.locationname}, has been commissioned. The next step is
|
||||
stocking the catalogue — pick products from the global catalogue, or upload the
|
||||
tenant's own list as a spreadsheet.
|
||||
</Text>
|
||||
|
||||
{/* The storefront code, at the one moment the person who provisioned
|
||||
the shop is holding its details.
|
||||
|
||||
It was reachable only from the branch user's own header, which
|
||||
is the wrong place for it: the code is what puts the store in
|
||||
front of a shopper at all — nobody can order from a shop they
|
||||
have not scanned — and the person onboarding it is the one who
|
||||
sends it to the merchant. `createtenantuser` returns the tenant
|
||||
and its primary outlet's id together, so it can be drawn here
|
||||
without a second read. */}
|
||||
{created?.tenantid && created?.locationid ? (
|
||||
<Card padding={3} elevation="low" variant="transparent">
|
||||
<StoreQrPanel
|
||||
tenantid={created.tenantid}
|
||||
locationid={created.locationid}
|
||||
locationname={form.locationname}
|
||||
where={[form.suburb, form.city].filter(Boolean).join(', ')}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
<HStack gap={1.5} wrap="wrap">
|
||||
<Button
|
||||
label="Stock from global catalogue"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
label="Back to stores"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/nearle/stores')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Onboard tenant"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack gap={3}>
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<TextInput
|
||||
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.tenantname}
|
||||
onChange={set('tenantname')}
|
||||
placeholder="e.g. Kaveri Groceries"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
placeholder="e.g. Kaveri Retail Pvt. Ltd."
|
||||
/>
|
||||
{/* Asked here because here is the only place it can be asked.
|
||||
The primary outlet and the merchant's own admin login are
|
||||
both created inside this one call, and the login is copied
|
||||
from the tenant row — so the name given here names the
|
||||
business AND the account that signs in. Left out, both are
|
||||
blank, which is the state every merchant is in today. */}
|
||||
<TextInput
|
||||
label={<span>Store admin <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.adminname}
|
||||
onChange={set('adminname')}
|
||||
placeholder="e.g. Ravi Kumar"
|
||||
description="The person who administers this shop. Their name goes on the store profile and on the admin login created with it — the account is provisioned as Admin, which is what this field is named after."
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
placeholder="admin@kaveri.com"
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.locationname}
|
||||
onChange={set('locationname')}
|
||||
placeholder="e.g. Kaveri RS Puram"
|
||||
/>
|
||||
<Selector
|
||||
label="Business category"
|
||||
options={(categories.data ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}))}
|
||||
isDisabled={categories.isLoading}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.address}
|
||||
onChange={set('address')}
|
||||
placeholder="e.g. 12, Avinashi Road"
|
||||
/>
|
||||
<div className="form-grid-4">
|
||||
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
|
||||
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
|
||||
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
|
||||
<TextInput
|
||||
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
value={form.postcode}
|
||||
onChange={set('postcode')}
|
||||
placeholder="641004"
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={17} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{error}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={mutation.isPending}
|
||||
isDisabled={!isComplete}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</form>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
/**
|
||||
* Rider partners — the companies that supply riders.
|
||||
*
|
||||
* ── Why this page did not exist ─────────────────────────────────────────────
|
||||
*
|
||||
* `getpartners` has always been readable and nothing on the platform could
|
||||
* create a partner, so the five that exist were inserted by hand — two are
|
||||
* still called "Test". Meanwhile 125 of 200 merchants already carry a
|
||||
* `partnerid`, and one partner supplies 48 shops while another supplies 63. The
|
||||
* relationship the whole delivery side rests on was real, live and unmanaged.
|
||||
*
|
||||
* ── What onboarding a partner records ───────────────────────────────────────
|
||||
*
|
||||
* Three things, and the last two are why the assign screen works at all:
|
||||
*
|
||||
* the district `partnerinfo.applocationid`, and `partnerlocations` beside
|
||||
* it. Every rider query joins through that id, so it has to be
|
||||
* a district Nearle actually services — see
|
||||
* `tamilNaduDistricts.ts` for why all 38 are shown anyway.
|
||||
* the merchant `tenants.partnerid`. This is what the assign screen reads to
|
||||
* decide whether to offer a partner tab at all.
|
||||
* the branch `tenantlocations.partnerid`. Which outlet they cover.
|
||||
*
|
||||
* A partner can also be attached to a merchant afterwards from that merchant's
|
||||
* own page — see `StoreDetailPage` — which is the ordinary case of a shop
|
||||
* changing partner without anybody re-onboarding the company.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, Plus, Truck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import {
|
||||
useAllPartners,
|
||||
useAppRegions,
|
||||
usePartnerRiderCounts,
|
||||
useTenantLocations,
|
||||
useTenants,
|
||||
} from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { PartnerRidersDrawer } from '../PartnerRidersDrawer';
|
||||
import { DrawerButton } from '@/features/store-admin/drawerKit';
|
||||
|
||||
interface PartnerRow extends Record<string, unknown> {
|
||||
partnerid: number;
|
||||
partnername: string;
|
||||
companyname: string;
|
||||
region: string;
|
||||
contact: string;
|
||||
status: string;
|
||||
/** How many riders they have. `null` while the count is still being read. */
|
||||
riders: number | null;
|
||||
}
|
||||
|
||||
export function PartnersPage() {
|
||||
const partners = useAllPartners();
|
||||
const regions = useAppRegions();
|
||||
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
|
||||
/** The partner whose riders are on screen, if any. */
|
||||
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
|
||||
|
||||
/* The fleet size per partner, read alongside the directory. Without it the
|
||||
Riders button is a door with nothing written on it. */
|
||||
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
|
||||
|
||||
const regionName = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const region of regions.data ?? []) {
|
||||
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
|
||||
}
|
||||
return map;
|
||||
}, [regions.data]);
|
||||
|
||||
const rows = useMemo<PartnerRow[]>(
|
||||
() =>
|
||||
partners.data.map((partner) => ({
|
||||
partnerid: partner.partnerid,
|
||||
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
|
||||
companyname: partner.companyname ?? '',
|
||||
region: regionName.get(partner.applocationid ?? 0) ?? '—',
|
||||
contact: partner.primarycontact || partner.contactno || '',
|
||||
status: partner.status || 'Active',
|
||||
riders: riderCounts.get(partner.partnerid) ?? null,
|
||||
})),
|
||||
[partners.data, regionName, riderCounts],
|
||||
);
|
||||
|
||||
const paged = usePaged(rows);
|
||||
|
||||
const columns: TableColumn<PartnerRow>[] = [
|
||||
{
|
||||
key: 'partnername',
|
||||
header: 'Partner',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.partnername}
|
||||
</Text>
|
||||
{row.companyname ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
header: 'Home region',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.region}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
header: 'Contact',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{row.contact || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
/* Both actions in ONE column with a header, rather than two unlabelled
|
||||
ones. The riders button carries the fleet size, because "Riders" alone
|
||||
asks you to open a drawer to learn whether there are any — and the
|
||||
answer is the reason you would open it. */
|
||||
key: 'actions',
|
||||
header: 'Fleet',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 184 },
|
||||
renderCell: (row) => (
|
||||
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
|
||||
<Button
|
||||
/* One width for every row, fixed in CSS. The label is a count, so
|
||||
it runs from one digit to three and a shrink-to-fit button leaves
|
||||
the column ragged — an uneven edge reads as disorder before the
|
||||
numbers themselves are read. */
|
||||
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Bike size={13} />}
|
||||
onClick={() => {
|
||||
const found = partners.data.find((p) => p.partnerid === row.partnerid);
|
||||
if (found) setRidersFor(found);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Edit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const found = partners.data.find((p) => p.partnerid === row.partnerid);
|
||||
if (found) setEditing(found);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Rider partners"
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard rider partner"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => setEditing('new')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
{/* No section heading. The page is already titled "Rider partners" and
|
||||
the table is the only thing on it — a second heading over one table
|
||||
restates the page and pushes the rows down a row for nothing. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={partners.isLoading}
|
||||
error={null}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle="No rider partners yet"
|
||||
emptyDescription="A rider partner is the company that supplies riders. Onboard one, choose its district, and attach it to the shop and branch it delivers for."
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<Table<PartnerRow>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
idKey="partnerid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="partners" />
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{ridersFor ? (
|
||||
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
|
||||
) : null}
|
||||
|
||||
{editing ? (
|
||||
<PartnerDrawer
|
||||
partner={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The form ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface FormState {
|
||||
partnername: string;
|
||||
companyname: string;
|
||||
registrationno: string;
|
||||
primarycontact: string;
|
||||
primaryemail: string;
|
||||
address: string;
|
||||
suburb: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
/** The serviced district they work out of — an `app_location` id. */
|
||||
applocationid: number;
|
||||
/** The merchant this partner delivers for. */
|
||||
tenantid: number;
|
||||
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
|
||||
locationid: number;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
partnername: '',
|
||||
companyname: '',
|
||||
registrationno: '',
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
applocationid: 0,
|
||||
tenantid: 0,
|
||||
locationid: 0,
|
||||
};
|
||||
|
||||
function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose: () => void }) {
|
||||
const client = useQueryClient();
|
||||
const regions = useAppRegions();
|
||||
const isNew = partner === null;
|
||||
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
partner
|
||||
? {
|
||||
partnername: partner.partnername ?? '',
|
||||
companyname: partner.companyname ?? '',
|
||||
registrationno: partner.registrationno ?? '',
|
||||
primarycontact: partner.primarycontact ?? partner.contactno ?? '',
|
||||
primaryemail: partner.primaryemail ?? '',
|
||||
address: partner.address ?? '',
|
||||
suburb: partner.suburb ?? '',
|
||||
city: partner.city ?? '',
|
||||
state: partner.state ?? '',
|
||||
postcode: '',
|
||||
applocationid: partner.applocationid ?? 0,
|
||||
tenantid: 0,
|
||||
locationid: 0,
|
||||
}
|
||||
: EMPTY,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
return (value: FormState[K]) => {
|
||||
setError(null);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
/* ── The district ─────────────────────────────────────────────────────────
|
||||
One per partner, chosen from all 38. Picking one Nearle does not run yet
|
||||
opens it — the partner is sent with the NAME and the server writes the
|
||||
`app_location` and `app_locationconfig` rows first. */
|
||||
const [districtSearch, setDistrictSearch] = useState('');
|
||||
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
|
||||
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
|
||||
const shown = useMemo(
|
||||
() => districts.filter((option) => matchDistrict(option, districtSearch)),
|
||||
[districts, districtSearch],
|
||||
);
|
||||
const chosenDistrict = districts.find((option) => option.name === district);
|
||||
|
||||
/* ── Who they deliver for ─────────────────────────────────────────────────
|
||||
A partner supplies riders TO a merchant's branch. Both links are written:
|
||||
`tenants.partnerid`, which is what the assign screen reads to decide
|
||||
whether to offer a partner tab at all, and `tenantlocations.partnerid`,
|
||||
which records the branch. Without the first the toggle never appears;
|
||||
without the second nothing says which outlet they cover.
|
||||
|
||||
Filtered to the district: a partner works one district, so a merchant in
|
||||
another is not somebody they can deliver for. `getalltenants` returns a row
|
||||
per BRANCH, and a branch's city is what places it — the tenant's own city
|
||||
is the head office and can differ. */
|
||||
const merchants = useTenants({ pageno: 1, pagesize: 200 });
|
||||
const merchantOptions = useMemo(() => {
|
||||
const here = district.trim().toLowerCase();
|
||||
const seen = new Map<number, string>();
|
||||
for (const entry of (merchants.data ?? []) as TenantInfo[]) {
|
||||
if (!entry.tenantid || seen.has(entry.tenantid)) continue;
|
||||
const city = (entry.city ?? '').trim().toLowerCase();
|
||||
if (here && city && city !== here) continue;
|
||||
seen.set(entry.tenantid, entry.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([value, label]) => ({ value: String(value), label }));
|
||||
}, [merchants.data, district]);
|
||||
|
||||
const branches = useTenantLocations(form.tenantid || undefined);
|
||||
const branchOptions = useMemo(
|
||||
() =>
|
||||
(branches.data ?? []).map((branch) => ({
|
||||
value: String(branch.locationid),
|
||||
label: branch.locationname || `Branch ${branch.locationid}`,
|
||||
})),
|
||||
[branches.data],
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NewPartner = {
|
||||
partnername: form.partnername.trim(),
|
||||
companyname: form.companyname.trim(),
|
||||
registrationno: form.registrationno.trim(),
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
state: form.state.trim(),
|
||||
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
|
||||
/*
|
||||
The district, by id when Nearle already runs it and by NAME when it
|
||||
does not. The name is what opens it — the server writes the region
|
||||
rows before the partner, so all 38 are real choices rather than three.
|
||||
*/
|
||||
applocationid: chosenDistrict?.applocationid ?? 0,
|
||||
...(chosenDistrict && chosenDistrict.applocationid === 0
|
||||
? { district: chosenDistrict.name }
|
||||
: {}),
|
||||
};
|
||||
return partner
|
||||
? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
|
||||
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
|
||||
},
|
||||
/*
|
||||
The placement is written after the partner exists, because it needs the
|
||||
id the create hands back.
|
||||
|
||||
Reported separately if it fails, and deliberately not rolled back: the
|
||||
partner is real either way and re-onboarding them would refuse on the
|
||||
duplicate contact number. Saying "the partner was created but could not be
|
||||
placed" is recoverable — the drawer stays open on the same form.
|
||||
*/
|
||||
onSuccess: async (partnerid) => {
|
||||
if (partnerid > 0 && form.tenantid > 0) {
|
||||
try {
|
||||
await tenantsApi.assignPartner(form.tenantid, partnerid);
|
||||
if (form.locationid > 0) {
|
||||
await tenantsApi.updateBranch({ locationid: form.locationid, partnerid });
|
||||
}
|
||||
} catch (cause) {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
|
||||
setError(
|
||||
`${form.partnername.trim()} was saved, but could not be assigned to that branch: ${errorMessage(cause)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
|
||||
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
onClose();
|
||||
},
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
form.partnername.trim() !== '' && form.primarycontact.trim() !== '' && form.applocationid > 0;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={isNew ? 'Onboard a rider partner' : form.partnername || 'Rider partner'}
|
||||
subtitle={isNew ? 'The company that supplies riders' : `Partner ${partner?.partnerid}`}
|
||||
width={520}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
|
||||
<DrawerButton
|
||||
label={save.isPending ? 'Saving…' : isNew ? 'Onboard rider partner' : 'Save changes'}
|
||||
variant="primary"
|
||||
icon={<Truck size={15} />}
|
||||
isDisabled={!isComplete || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
{error ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Partner name"
|
||||
size="sm"
|
||||
value={form.partnername}
|
||||
onChange={set('partnername')}
|
||||
placeholder="e.g. Xpress-Cbe-Main"
|
||||
description="How the partner is named on rider records and in the assign picker."
|
||||
/>
|
||||
<TextInput
|
||||
label="Registered company"
|
||||
size="sm"
|
||||
value={form.companyname}
|
||||
onChange={set('companyname')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Primary contact"
|
||||
size="sm"
|
||||
value={form.primarycontact}
|
||||
onChange={set('primarycontact')}
|
||||
placeholder="9876543210"
|
||||
description="One partner per number — the server refuses a second."
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
size="sm"
|
||||
value={form.primaryemail}
|
||||
onChange={set('primaryemail')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Registration number"
|
||||
size="sm"
|
||||
value={form.registrationno}
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
|
||||
{/* ── District ──────────────────────────────────────────────────────
|
||||
All 38 of Tamil Nadu's districts, searchable, with only the ones
|
||||
Nearle services selectable. A partner placed in a district that has
|
||||
no `app_location` row is a partner whose riders no query returns —
|
||||
`getriders` filters on that id — so an unserviced district is shown
|
||||
and refused rather than hidden, because "Erode is not open yet" is
|
||||
an answer and a missing Erode is not. */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
District
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Search districts"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={districtSearch}
|
||||
onChange={setDistrictSearch}
|
||||
placeholder="Search all 38 districts…"
|
||||
hasClear
|
||||
/>
|
||||
{/* A single-select list, not a cloud of chips: one partner works one
|
||||
district, so this is a choice with one answer and it should read
|
||||
like one. Running districts carry a tick, new ones say what will
|
||||
happen — the difference is operational, not a restriction. */}
|
||||
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
|
||||
{shown.map((option) => {
|
||||
const running = isRunning(option);
|
||||
const chosen = district === option.name;
|
||||
return (
|
||||
<button
|
||||
key={option.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={chosen}
|
||||
className="district-row"
|
||||
data-chosen={chosen ? 'yes' : 'no'}
|
||||
onClick={() => {
|
||||
setDistrict(option.name);
|
||||
// The merchant is district-scoped, so changing the district
|
||||
// invalidates it — keeping it would attach a partner to a
|
||||
// shop in a place they do not work.
|
||||
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
|
||||
}}
|
||||
>
|
||||
<span className="district-row-name">{option.name}</span>
|
||||
<span className="district-row-state" data-running={running ? 'yes' : 'no'}>
|
||||
{running ? 'Running' : 'New district'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{shown.length === 0 ? (
|
||||
<Text type="body" size="sm" color="secondary" style={{ padding: '10px 12px' }}>
|
||||
No district matches “{districtSearch}”.
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{chosenDistrict
|
||||
? isRunning(chosenDistrict)
|
||||
? `Riders are listed against ${chosenDistrict.name}.`
|
||||
: `${chosenDistrict.name} will be opened when this partner is saved.`
|
||||
: 'One district per partner. Choosing one Nearle does not run yet opens it.'}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* ── Who they deliver for ──────────────────────────────────────────
|
||||
The merchant, then the branch. Both links are written: the merchant
|
||||
one is what the assign screen reads to decide whether to offer a
|
||||
partner tab at all, and the branch one records which outlet. */}
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Delivers for
|
||||
</Text>
|
||||
<Selector
|
||||
label="Merchant"
|
||||
size="sm"
|
||||
value={form.tenantid ? String(form.tenantid) : ''}
|
||||
onChange={(value) => {
|
||||
// A new merchant clears the branch with it — keeping it would
|
||||
// leave another shop's outlet id attached to this partner.
|
||||
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
|
||||
}}
|
||||
options={merchantOptions}
|
||||
placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
|
||||
/>
|
||||
<Selector
|
||||
label="Branch"
|
||||
size="sm"
|
||||
value={form.locationid ? String(form.locationid) : ''}
|
||||
onChange={(value) => set('locationid')(Number(value) || 0)}
|
||||
options={branchOptions}
|
||||
isDisabled={!form.tenantid}
|
||||
placeholder={
|
||||
!form.tenantid
|
||||
? 'Choose a merchant first'
|
||||
: branches.isLoading
|
||||
? 'Loading branches…'
|
||||
: 'Choose the branch they cover'
|
||||
}
|
||||
/>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Optional. Set it and this partner’s riders become an option on that shop’s assign
|
||||
screen, beside any riders it hired itself.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<TextInput label="Address" size="sm" value={form.address} onChange={set('address')} />
|
||||
<HStack gap={1}>
|
||||
<TextInput label="Area" size="sm" value={form.suburb} onChange={set('suburb')} />
|
||||
<TextInput label="City" size="sm" value={form.city} onChange={set('city')} />
|
||||
</HStack>
|
||||
<HStack gap={1}>
|
||||
<TextInput label="State" size="sm" value={form.state} onChange={set('state')} />
|
||||
<TextInput label="Postcode" size="sm" value={form.postcode} onChange={set('postcode')} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
|
||||
import { TablePager } from '@/components/TablePager';
|
||||
import { usePaged } from '@/components/usePaged';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
|
||||
import { PartnerAssignDrawer } from '../PartnerAssignDrawer';
|
||||
|
||||
interface BranchRow extends Record<string, unknown> {
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
city: string;
|
||||
hours: string;
|
||||
radius: string;
|
||||
status: string;
|
||||
orders: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
function money(value: number | undefined): string {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '₹0';
|
||||
return `₹${value.toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tenant: its branches, and how each is performing.
|
||||
*
|
||||
* Order figures come from `/orders/getlocationsummary`, which is scoped to the
|
||||
* tenant and returns one row per branch. Counter sales are NOT folded in here:
|
||||
* the POS endpoints take a single required locationid, so a tenant-wide till
|
||||
* figure would mean one request per branch, and a blended number would be
|
||||
* eventually consistent in a way this page cannot honestly caption. Branch-level
|
||||
* POS lives on the branch screen instead.
|
||||
*/
|
||||
export function StoreDetailPage() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const tenantid = Number(tenantId ?? 0) || undefined;
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: locations, isLoading, error } = useTenantLocations(tenantid);
|
||||
const summary = useLocationSummary(tenantid);
|
||||
const orders = useOrderSummary(tenantid);
|
||||
|
||||
const tenant = useMemo(
|
||||
() => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid),
|
||||
[tenants, tenantid],
|
||||
);
|
||||
|
||||
const rows = useMemo<BranchRow[]>(() => {
|
||||
const perLocation = new Map<number, LocationOrderSummary>();
|
||||
for (const entry of summary.data ?? []) {
|
||||
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
|
||||
}
|
||||
|
||||
return ((locations ?? []) as TenantLocation[]).map((branch) => {
|
||||
const stats = perLocation.get(branch.locationid);
|
||||
return {
|
||||
locationid: branch.locationid,
|
||||
locationname: branch.locationname,
|
||||
city: [branch.suburb, branch.city].filter(Boolean).join(', '),
|
||||
hours:
|
||||
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
|
||||
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
|
||||
status: branch.status ?? 'Unknown',
|
||||
orders: Number(stats?.totalorders ?? 0),
|
||||
revenue: Number(stats?.revenue ?? 0),
|
||||
};
|
||||
});
|
||||
}, [locations, summary.data]);
|
||||
|
||||
const paged = usePaged(rows);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const branches = rows.length;
|
||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||
const totalOrders = rows.reduce((sum, row) => sum + row.orders, 0);
|
||||
const totalRevenue = rows.reduce((sum, row) => sum + row.revenue, 0);
|
||||
const cancelled = Number(orders.data?.cancelled ?? 0);
|
||||
return { branches, active, totalOrders, totalRevenue, cancelled };
|
||||
}, [rows, orders.data]);
|
||||
|
||||
/** The branch whose code is on screen, if any. */
|
||||
const [qrFor, setQrFor] = useState<BranchRow | null>(null);
|
||||
/** Open while the merchant's delivery partner is being changed. */
|
||||
const [isPartnerOpen, setPartnerOpen] = useState(false);
|
||||
|
||||
const columns: TableColumn<BranchRow>[] = [
|
||||
{
|
||||
key: 'locationname',
|
||||
header: 'Branch',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.locationname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.city || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
header: 'Hours',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.hours}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'radius',
|
||||
header: 'Radius',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 90 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" hasTabularNumbers>
|
||||
{row.radius}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
header: 'Orders',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
hasTabularNumbers
|
||||
color={row.orders === 0 ? 'disabled' : 'primary'}
|
||||
>
|
||||
{row.orders}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
header: 'Revenue',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
hasTabularNumbers
|
||||
color={row.revenue === 0 ? 'disabled' : 'primary'}
|
||||
>
|
||||
{money(row.revenue)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
/* The storefront code, per branch, after the day it was commissioned.
|
||||
Shown at creation too — but a poster gets lost, a shop reopens, a
|
||||
merchant asks for it again a month later, and the person they ask is
|
||||
whoever is looking at this page. The code is derived from the two ids
|
||||
on this row, so there is nothing to look up and nothing to reissue. */
|
||||
key: 'qr',
|
||||
header: 'QR',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 84 },
|
||||
renderCell: (row) => (
|
||||
<Button
|
||||
label="QR"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<QrCode size={14} />}
|
||||
onClick={() => setQrFor(row)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title={tenant?.tenantname ?? 'Tenant'}
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Delivery partner"
|
||||
variant="secondary"
|
||||
icon={<Truck size={14} />}
|
||||
onClick={() => setPartnerOpen(true)}
|
||||
/>
|
||||
<Button
|
||||
label="Stock catalogue"
|
||||
variant="secondary"
|
||||
href={`/nearle/catalogue?tenantid=${tenantid ?? ''}`}
|
||||
as={Link}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Branches"
|
||||
value={String(totals.branches)}
|
||||
note={`${totals.active} active`}
|
||||
tone="accent"
|
||||
icon={<Store size={15} />}
|
||||
fill={totals.branches ? totals.active / totals.branches : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Orders"
|
||||
value={String(totals.totalOrders)}
|
||||
note="all statuses"
|
||||
tone="neutral"
|
||||
icon={<ShoppingCart size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Revenue"
|
||||
value={money(totals.totalRevenue)}
|
||||
note="from order summary"
|
||||
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cancelled"
|
||||
value={String(totals.cancelled)}
|
||||
note={
|
||||
totals.totalOrders
|
||||
? `${Math.round((totals.cancelled / totals.totalOrders) * 100)}% of orders`
|
||||
: 'no orders yet'
|
||||
}
|
||||
tone={totals.cancelled > 0 ? 'error' : 'neutral'}
|
||||
icon={<TriangleAlert size={15} />}
|
||||
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
|
||||
/>
|
||||
{/* No "Catalogue — Linked" tile.
|
||||
|
||||
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
|
||||
tenant row came back at all — it described the tenant existing, not
|
||||
the catalogue. `products/getimportedcatalogueproducts` could give a
|
||||
real imported count here; until something calls it, a tile that is
|
||||
always green is worse than no tile. */}
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Branches"
|
||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle="No branches yet"
|
||||
/* No CTA here: this tenant's own Administrator commissions their
|
||||
outlets. Naming who acts is more useful than a button that would
|
||||
take an action out of their hands. */
|
||||
emptyDescription="This tenant's Administrator opens their outlets from their own workspace."
|
||||
>
|
||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||
than dropping any of them. The page itself never scrolls wide. */}
|
||||
<div className="table-scroll">
|
||||
<Table<BranchRow>
|
||||
data={paged.rows}
|
||||
columns={columns}
|
||||
idKey="locationid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
<TablePager paged={paged} label="branches" />
|
||||
</DataState>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
{isPartnerOpen && tenantid ? (
|
||||
<PartnerAssignDrawer
|
||||
tenantid={tenantid}
|
||||
tenantname={tenant?.tenantname ?? 'this merchant'}
|
||||
currentPartnerId={Number((tenant as unknown as Record<string, number>)?.['partnerid'] ?? 0)}
|
||||
onClose={() => setPartnerOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{qrFor && tenantid ? (
|
||||
<Drawer
|
||||
title="Store QR code"
|
||||
subtitle={qrFor.locationname}
|
||||
width={400}
|
||||
onClose={() => setQrFor(null)}
|
||||
>
|
||||
<StoreQrPanel
|
||||
tenantid={tenantid}
|
||||
locationid={qrFor.locationid}
|
||||
locationname={qrFor.locationname}
|
||||
{...(qrFor.city ? { where: String(qrFor.city) } : {})}
|
||||
/>
|
||||
</Drawer>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Building2, Plus, Store, Users } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/** A tenant, with its branches folded in. */
|
||||
interface TenantRow extends Record<string, unknown> {
|
||||
tenantid: number;
|
||||
tenantname: string;
|
||||
companyname: string;
|
||||
city: string;
|
||||
branches: number;
|
||||
status: string;
|
||||
primaryemail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Nearle Admin's home: every tenant on the platform, and how many branches
|
||||
* sit under each.
|
||||
*
|
||||
* `getalltenants` returns one row per tenant-location pair, so the rows are
|
||||
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
|
||||
* branches reads as six tenants.
|
||||
*/
|
||||
type Tab = 'directory' | 'pending';
|
||||
|
||||
/** Rows per page. One more than this is fetched, to know whether there is a next. */
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function StoresPage() {
|
||||
const [tab, setTab] = useState<Tab>('directory');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
/**
|
||||
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
|
||||
* and has no total, so paging is "ask for one more than we show and see if it
|
||||
* comes back". A platform list read whole is fine at twenty tenants and not
|
||||
* at two thousand.
|
||||
*/
|
||||
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
|
||||
|
||||
/**
|
||||
* The queue of merchants awaiting approval.
|
||||
*
|
||||
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
|
||||
* `getalltenants` at all, so without this they are invisible. Nothing here
|
||||
* can approve one — `approved` is writable only at creation — so this lists
|
||||
* and says so.
|
||||
*/
|
||||
const pending = useTenantsByApproval('pending');
|
||||
|
||||
const rows = useMemo<TenantRow[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<number, TenantRow>();
|
||||
|
||||
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
|
||||
const existing = grouped.get(tenant.tenantid);
|
||||
if (existing) {
|
||||
existing.branches += 1;
|
||||
continue;
|
||||
}
|
||||
grouped.set(tenant.tenantid, {
|
||||
tenantid: tenant.tenantid,
|
||||
tenantname: tenant.tenantname,
|
||||
companyname: tenant.companyname ?? '',
|
||||
city: tenant.city ?? '',
|
||||
branches: 1,
|
||||
status: tenant.status ?? 'Unknown',
|
||||
primaryemail: tenant.primaryemail ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const all = [...grouped.values()];
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return all;
|
||||
return all.filter(
|
||||
(row) =>
|
||||
row.tenantname.toLowerCase().includes(term) ||
|
||||
row.companyname.toLowerCase().includes(term) ||
|
||||
row.city.toLowerCase().includes(term),
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const tenants = rows.length;
|
||||
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
|
||||
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
|
||||
return { tenants, branches, active };
|
||||
}, [rows]);
|
||||
|
||||
const columns: TableColumn<TenantRow>[] = [
|
||||
{
|
||||
key: 'tenantname',
|
||||
header: 'Tenant',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.tenantname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.companyname || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'city',
|
||||
header: 'City',
|
||||
width: { type: 'proportional', value: 1.5 },
|
||||
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'branches',
|
||||
header: 'Branches',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
{row.branches}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'primaryemail',
|
||||
header: 'Primary admin',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{row.primaryemail || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 110 },
|
||||
renderCell: (row) => (
|
||||
<Link
|
||||
to={`/nearle/stores/${row.tenantid}`}
|
||||
style={{
|
||||
color: 'var(--color-brand)',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Open →
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
{/* No `isLive` here. Both of this page's reads — `useTenants` and
|
||||
`useTenantsByApproval` — use the `stable` query options: a 5-minute
|
||||
staleTime and no refetchInterval. The pill claimed a freshness the page
|
||||
does not have. The pages that keep it (Console, Sales, Counters, store
|
||||
detail) poll on a real interval. */}
|
||||
<PageHeader
|
||||
title="Stores"
|
||||
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard tenant"
|
||||
variant="primary"
|
||||
icon={<Plus size={15} />}
|
||||
href="/nearle/onboard/tenant"
|
||||
as={Link}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Tenants"
|
||||
value={String(totals.tenants)}
|
||||
note={`${totals.active} active`}
|
||||
tone="accent"
|
||||
icon={<Building2 size={15} />}
|
||||
fill={totals.tenants ? totals.active / totals.tenants : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Branches"
|
||||
value={String(totals.branches)}
|
||||
note="across the network"
|
||||
tone="neutral"
|
||||
icon={<Store size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg branches"
|
||||
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
|
||||
note="per tenant"
|
||||
tone="neutral"
|
||||
icon={<Users size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Directory"
|
||||
isActive={tab === 'directory'}
|
||||
onClick={() => setTab('directory')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Awaiting approval"
|
||||
isActive={tab === 'pending'}
|
||||
badge={pending.data?.length || undefined}
|
||||
onClick={() => setTab('pending')}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{tab === 'pending' ? (
|
||||
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Tenant directory"
|
||||
note={`${rows.length} shown`}
|
||||
action={
|
||||
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
|
||||
<TextInput
|
||||
label="Search tenants"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, company or city…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={rows.length === 0}
|
||||
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
|
||||
emptyDescription={
|
||||
search
|
||||
? 'Try a different name, company or city.'
|
||||
: 'Onboard the first merchant group to get started.'
|
||||
}
|
||||
emptyAction={
|
||||
search ? undefined : (
|
||||
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Columns carry meaning, so the table scrolls sideways rather
|
||||
than dropping any of them. The page itself never scrolls wide. */}
|
||||
<div className="table-scroll">
|
||||
<Table<TenantRow>
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="tenantid"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
|
||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Page {page}
|
||||
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Previous"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={page === 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merchants that have been created but never approved.
|
||||
*
|
||||
* Read-only, and it says why on the panel rather than offering a button that
|
||||
* cannot work: `tenants.approved` is written once, at creation, and there is no
|
||||
* update or approve endpoint anywhere in the backend. Until there is, this is
|
||||
* the only place these rows are visible at all — `getalltenants` cannot see
|
||||
* them.
|
||||
*/
|
||||
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the approval queue…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every merchant on the platform has been approved.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
These merchants exist but are not approved, so they do not appear in the directory.
|
||||
Approving is a database change today — the backend has no endpoint for it.
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Merchant</Th>
|
||||
<Th>Company</Th>
|
||||
<Th>Contact</Th>
|
||||
<Th>City</Th>
|
||||
<Th>Tenant id</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.tenantid}-${row.locationid}`}>
|
||||
<Td isStrong>{row.tenantname}</Td>
|
||||
<Td isMuted>{row.companyname || '—'}</Td>
|
||||
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
|
||||
<Td isMuted>{row.city || '—'}</Td>
|
||||
<Td isMuted>{row.tenantid}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useDateScope } from '@/components/shell/DateScope';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
|
||||
|
||||
/**
|
||||
* Every spreadsheet on the platform, whoever sent it.
|
||||
*
|
||||
* No tenant scope, which is the whole difference from the two store versions:
|
||||
* the Nearle Admin is who chases the catalogue team when a drop sits unreviewed,
|
||||
* and they cannot do that from one merchant at a time. The panel shows the
|
||||
* merchant name on each row when it is not given one.
|
||||
*/
|
||||
export function UploadsPage() {
|
||||
const navigate = useNavigate();
|
||||
/*
|
||||
No date filter by default, unlike the reporting pages.
|
||||
|
||||
This is a receipt log, and the reason somebody opens it is to find an upload
|
||||
from a while ago. Starting at month-to-date hid every receipt older than the
|
||||
1st — the list read "Nothing matches" while the tab beside it said there were
|
||||
two. The read already returns only the most recent fifty, so the range is a
|
||||
narrowing rather than the thing that makes the page tractable.
|
||||
*/
|
||||
const dates = useDateScope();
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Uploads"
|
||||
/>
|
||||
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
|
||||
different action from a merchant sending their own list — so this goes
|
||||
to the page that owns it rather than opening a merchant's drawer. */}
|
||||
<UploadsPanel range={dates.range} onClearRange={dates.clear} onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* The district list, and the line between "not open yet" and "not a place".
|
||||
*
|
||||
* A partner placed in a district with no `app_location` row is a partner whose
|
||||
* riders no query returns — `getriders` filters on that id and `CreateRider`
|
||||
* refuses a region with no config. Every district can be chosen — picking one
|
||||
* that is not running yet OPENS it, writing both rows server-side — so what
|
||||
* these pin is that the list is complete and that the running ones carry the id
|
||||
* they will be saved with.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
TAMIL_NADU_DISTRICTS,
|
||||
districtOptions,
|
||||
isRunning,
|
||||
matchDistrict,
|
||||
} from './tamilNaduDistricts';
|
||||
|
||||
const live = [
|
||||
{ applocationid: 1, locationname: 'Coimbatore' },
|
||||
{ applocationid: 2, locationname: 'Madurai' },
|
||||
{ applocationid: 23, locationname: 'Nagercoil' },
|
||||
];
|
||||
|
||||
test('all 38 districts are offered, not just the three that are running', () => {
|
||||
assert.equal(TAMIL_NADU_DISTRICTS.length, 38);
|
||||
assert.ok(districtOptions(live).length >= 38);
|
||||
});
|
||||
|
||||
test('a running district carries the region id it will be saved with', () => {
|
||||
const options = districtOptions(live);
|
||||
const coimbatore = options.find((option) => option.name === 'Coimbatore');
|
||||
assert.equal(coimbatore?.applocationid, 1);
|
||||
assert.equal(isRunning(coimbatore!), true);
|
||||
});
|
||||
|
||||
// A district Nearle does not run yet carries no id — the form sends its NAME
|
||||
// instead and the server opens it. Reporting 0 is what makes the form say
|
||||
// "Erode will be opened" rather than silently saving a partner into nothing.
|
||||
test('a district Nearle does not run yet is offered, with no id yet', () => {
|
||||
const erode = districtOptions(live).find((option) => option.name === 'Erode');
|
||||
assert.ok(erode, 'Erode is a district and belongs in the list');
|
||||
assert.equal(erode!.applocationid, 0);
|
||||
assert.equal(isRunning(erode!), false);
|
||||
});
|
||||
|
||||
// Nagercoil is the district's town; the district is Kanyakumari. Matching on
|
||||
// name means it arrives as its own entry rather than being lost — which is the
|
||||
// safe direction: a region somebody is already working must never disappear.
|
||||
test('a running region whose name is not one of the 38 is still offered', () => {
|
||||
const options = districtOptions(live);
|
||||
const nagercoil = options.find((option) => option.name === 'Nagercoil');
|
||||
assert.equal(nagercoil?.applocationid, 23);
|
||||
});
|
||||
|
||||
test('matching is case-insensitive and matches anywhere in the name', () => {
|
||||
const [erode] = districtOptions(live).filter((option) => option.name === 'Erode');
|
||||
assert.equal(matchDistrict(erode!, 'ero'), true);
|
||||
assert.equal(matchDistrict(erode!, 'ROD'), true);
|
||||
assert.equal(matchDistrict(erode!, ''), true, 'an empty box hides nothing');
|
||||
assert.equal(matchDistrict(erode!, 'salem'), false);
|
||||
});
|
||||
|
||||
test('with no regions read yet, every district reads as new and none is lost', () => {
|
||||
const options = districtOptions([]);
|
||||
assert.equal(options.length, 38);
|
||||
assert.equal(options.every((option) => !isRunning(option)), true);
|
||||
});
|
||||
|
||||
test('the list is alphabetical, so a long list can be scanned', () => {
|
||||
const names = districtOptions(live).map((option) => option.name);
|
||||
assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* The districts of Tamil Nadu, for the partner onboarding form.
|
||||
*
|
||||
* ── Why this is a list in the code ──────────────────────────────────────────
|
||||
*
|
||||
* The platform has no district master. `app_location` holds the districts
|
||||
* Nearle actually RUNS — three of them when this was written: Coimbatore,
|
||||
* Madurai and Nagercoil — each with a radius, opening hours and an image,
|
||||
* because it is an operating record rather than a geography one.
|
||||
*
|
||||
* A picker built from `app_location` alone can therefore offer three names for
|
||||
* a state with thirty-eight districts. This list is the geography; the regions
|
||||
* read back from the API say which of them are already running.
|
||||
*
|
||||
* ── Every district can be chosen ────────────────────────────────────────────
|
||||
*
|
||||
* Choosing one that is not running yet OPENS it: the partner is sent with a
|
||||
* district NAME and the server writes the `app_location` and
|
||||
* `app_locationconfig` rows before creating the partner. Both rows matter —
|
||||
* `getriders` joins the first and `CreateRider` refuses a region missing the
|
||||
* second — so opening a district means both or neither.
|
||||
*
|
||||
* `isRunning` therefore marks what already exists, not what is permitted. It is
|
||||
* shown so an operator can tell "this is where we work" from "this will be a
|
||||
* new district", which is a real operational difference and not a restriction.
|
||||
*/
|
||||
|
||||
/** All 38 districts, alphabetically. Names as the state government writes them. */
|
||||
export const TAMIL_NADU_DISTRICTS = [
|
||||
'Ariyalur',
|
||||
'Chengalpattu',
|
||||
'Chennai',
|
||||
'Coimbatore',
|
||||
'Cuddalore',
|
||||
'Dharmapuri',
|
||||
'Dindigul',
|
||||
'Erode',
|
||||
'Kallakurichi',
|
||||
'Kanchipuram',
|
||||
'Kanyakumari',
|
||||
'Karur',
|
||||
'Krishnagiri',
|
||||
'Madurai',
|
||||
'Mayiladuthurai',
|
||||
'Nagapattinam',
|
||||
'Namakkal',
|
||||
'Nilgiris',
|
||||
'Perambalur',
|
||||
'Pudukkottai',
|
||||
'Ramanathapuram',
|
||||
'Ranipet',
|
||||
'Salem',
|
||||
'Sivaganga',
|
||||
'Tenkasi',
|
||||
'Thanjavur',
|
||||
'Theni',
|
||||
'Thoothukudi',
|
||||
'Tiruchirappalli',
|
||||
'Tirunelveli',
|
||||
'Tirupathur',
|
||||
'Tiruppur',
|
||||
'Tiruvallur',
|
||||
'Tiruvannamalai',
|
||||
'Tiruvarur',
|
||||
'Vellore',
|
||||
'Viluppuram',
|
||||
'Virudhunagar',
|
||||
] as const;
|
||||
|
||||
export type TamilNaduDistrict = (typeof TAMIL_NADU_DISTRICTS)[number];
|
||||
|
||||
/** One district as the form sees it: its name, and whether it is running. */
|
||||
export interface DistrictOption {
|
||||
name: string;
|
||||
/** The `app_location` id when the district already runs, 0 when it is new. */
|
||||
applocationid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The district list, matched against the regions the platform actually runs.
|
||||
*
|
||||
* Matched on NAME, case-insensitively, because that is the only thing the two
|
||||
* sources share — `app_location` has no district column, and its `locationname`
|
||||
* is the district's name ("Coimbatore", "Madurai", "Nagercoil").
|
||||
*
|
||||
* A serviced region whose name is not one of the 38 is still listed rather than
|
||||
* dropped. Nearle opening a place this file has not heard of is a reason to
|
||||
* update the file, not a reason to hide a region somebody is already working.
|
||||
*/
|
||||
export function districtOptions(
|
||||
regions: readonly { applocationid: number; locationname?: string }[] = [],
|
||||
): DistrictOption[] {
|
||||
const serviced = new Map<string, number>();
|
||||
for (const region of regions) {
|
||||
const name = (region.locationname ?? '').trim();
|
||||
if (name) serviced.set(name.toLowerCase(), region.applocationid);
|
||||
}
|
||||
|
||||
const options: DistrictOption[] = TAMIL_NADU_DISTRICTS.map((name) => ({
|
||||
name,
|
||||
applocationid: serviced.get(name.toLowerCase()) ?? 0,
|
||||
}));
|
||||
|
||||
// A running region whose name is not one of the 38 — Nagercoil is the town,
|
||||
// the district is Kanyakumari — is still listed rather than dropped. A place
|
||||
// somebody is already working must never disappear from the picker.
|
||||
const known = new Set(TAMIL_NADU_DISTRICTS.map((name) => name.toLowerCase()));
|
||||
for (const region of regions) {
|
||||
const name = (region.locationname ?? '').trim();
|
||||
if (name && !known.has(name.toLowerCase())) {
|
||||
options.push({ name, applocationid: region.applocationid });
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Nearle already runs this district.
|
||||
*
|
||||
* Not a permission — every district can be chosen. This distinguishes "we work
|
||||
* here" from "this will be opened", which the form says out loud because
|
||||
* opening a district writes rows and is worth knowing before you press save.
|
||||
*/
|
||||
export function isRunning(option: DistrictOption): boolean {
|
||||
return option.applocationid > 0;
|
||||
}
|
||||
|
||||
/** Filter for the search box. Matches anywhere in the name, case-insensitively. */
|
||||
export function matchDistrict(option: DistrictOption, term: string): boolean {
|
||||
const needle = term.trim().toLowerCase();
|
||||
return needle === '' || option.name.toLowerCase().includes(needle);
|
||||
}
|
||||
@@ -231,6 +231,23 @@ export function OnboardingPage() {
|
||||
persist(completeStep(state, step, nextOf(step)));
|
||||
}
|
||||
|
||||
/*
|
||||
Each step of the wizard starts at the top of itself.
|
||||
|
||||
The steps are STATE, not routes, so the shell's own reset — which keys on the
|
||||
pathname — never fires here: the URL is `/admin/onboarding` for all seven of
|
||||
them. Finishing the inventory step from the bottom of a long list and
|
||||
pressing Save & continue therefore opened the next step already scrolled past
|
||||
its heading and its explanation, which is exactly the part a person setting
|
||||
up a shop for the first time needs to read.
|
||||
|
||||
Instant, for the same reason the shell's is: this replaces the whole page
|
||||
body, so there is nothing continuous for a smooth travel to connect.
|
||||
*/
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
}, [step]);
|
||||
|
||||
const heading = HEADINGS[step];
|
||||
const isFirst = step === 'welcome';
|
||||
const isLast = step === 'done';
|
||||
@@ -284,7 +301,7 @@ export function OnboardingPage() {
|
||||
{step === 'catalogue' ? (
|
||||
<CatalogueStep
|
||||
productCount={productCount}
|
||||
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
|
||||
onUpload={() => navigate('/admin/uploads?upload=1&setup=catalogue')}
|
||||
/* The catalogue tab, not the products list. This pointed at
|
||||
`/admin/inventory` bare, which lands on Products — so "import from
|
||||
the catalogue" showed a merchant their own empty product list. */
|
||||
@@ -295,7 +312,7 @@ export function OnboardingPage() {
|
||||
{step === 'inventory' ? (
|
||||
<InventoryStep
|
||||
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
|
||||
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
|
||||
onUpload={() => navigate('/admin/uploads?upload=1&setup=inventory')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte
|
||||
return (
|
||||
<VStack gap={3} className="ob-panel">
|
||||
{productCount > 0 ? (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
You already have {productCount} product{productCount === 1 ? '' : 's'}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) {
|
||||
{/* Only when it applies. Nothing below exists for a collection-only shop,
|
||||
and rendering it disabled would be showing work that is not theirs. */}
|
||||
{value.offersDelivery === true ? (
|
||||
<Card padding={3} elevation="low" className="ob-panel">
|
||||
<Card padding={3} className="ob-panel">
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
|
||||
@@ -53,7 +53,7 @@ const TIPS = [
|
||||
export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
|
||||
return (
|
||||
<VStack gap={3} className="ob-panel">
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
@@ -80,7 +80,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
||||
</Card>
|
||||
|
||||
<div className="ob-two">
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={2}>
|
||||
<HStack justify="between" align="start" gap={2} wrap="wrap">
|
||||
<VStack gap={0.5}>
|
||||
@@ -128,7 +128,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={1.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Tips
|
||||
|
||||
@@ -158,7 +158,7 @@ function Group({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card padding={3} elevation="low">
|
||||
<Card padding={3}>
|
||||
<VStack gap={2}>
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
|
||||
@@ -113,7 +113,7 @@ export function WelcomeStep({
|
||||
|
||||
<div className="ob-benefits">
|
||||
{benefits.map(({ icon: Icon, title, body }) => (
|
||||
<Card key={title} padding={3} elevation="low">
|
||||
<Card key={title} padding={3}>
|
||||
<VStack gap={1}>
|
||||
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -110,9 +110,9 @@ export function StoreQrPanel({
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 16,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--color-line)',
|
||||
borderRadius: 'var(--card-radius)',
|
||||
background: 'var(--card-bg)',
|
||||
border: 'var(--card-border)',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
|
||||
236
src/features/store-admin/ActivePanel.tsx
Normal file
236
src/features/store-admin/ActivePanel.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { BatteryLow, Info, MapPin, Navigation, WifiOff } from 'lucide-react';
|
||||
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
|
||||
import type { Stop } from './dispatchModel';
|
||||
import { useRiderLive } from '@/queries/hooks';
|
||||
import { mapStop } from './deliveryTrack';
|
||||
import {
|
||||
activeBoard,
|
||||
agoOf,
|
||||
concerns,
|
||||
distanceLabel,
|
||||
readSnapshot,
|
||||
type LiveRider,
|
||||
} from './riderLive';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* Who is out right now, closest to finishing first.
|
||||
*
|
||||
* ── Why the order is "distance to the drop" ─────────────────────────────────
|
||||
*
|
||||
* A dispatcher watching a live board is answering one question: who will be
|
||||
* free next. Sorting by that puts the answer at the top, and it re-sorts itself
|
||||
* as riders move. Sorting by name or by order count makes the reader do the
|
||||
* comparison themselves, every fifteen seconds.
|
||||
*
|
||||
* ── Live is a claim, and most of these positions are not ────────────────────
|
||||
*
|
||||
* The service answers for every rider, with their LAST known fix however old —
|
||||
* riders on this platform routinely come back with positions days stale and
|
||||
* nothing in the payload admitting it. So each card carries its age, the map
|
||||
* draws a stale fix hollow, and anything over an hour old is called out rather
|
||||
* than mixed in. A board that shows a twelve-day-old dot next to a live one is
|
||||
* worse than no board.
|
||||
*/
|
||||
export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
|
||||
const mapped = useMemo(() => stops.map(mapStop), [stops]);
|
||||
|
||||
/* Only riders carrying something today — polling the whole roster would ask a
|
||||
third-party service about people who are not working. */
|
||||
const userids = useMemo(
|
||||
() => [...new Set(mapped.map((stop) => stop.userid).filter((id) => id > 0))],
|
||||
[mapped],
|
||||
);
|
||||
|
||||
const live = useRiderLive(userids);
|
||||
|
||||
const nameOf = useMemo(() => {
|
||||
const names = new Map<number, string>();
|
||||
for (const stop of mapped) {
|
||||
if (stop.userid > 0 && stop.ridername && !names.has(stop.userid)) {
|
||||
names.set(stop.userid, stop.ridername);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [mapped]);
|
||||
|
||||
const riders = useMemo(() => {
|
||||
const out: LiveRider[] = [];
|
||||
for (const userid of userids) {
|
||||
const read = readSnapshot(
|
||||
live.data.get(userid) ?? null,
|
||||
nameOf.get(userid) ?? `Rider ${userid}`,
|
||||
);
|
||||
if (read) out.push(read);
|
||||
}
|
||||
return out;
|
||||
}, [userids, live.data, nameOf]);
|
||||
|
||||
/* The drop for whichever order a rider says they are on. Matched on the
|
||||
order id the RIDER reports, not on our idea of what they should be
|
||||
carrying — the two disagree the moment somebody reassigns a job. */
|
||||
const dropOf = useMemo(() => {
|
||||
const byOrder = new Map<string, (typeof mapped)[number]>();
|
||||
for (const stop of mapped) if (stop.orderid) byOrder.set(stop.orderid, stop);
|
||||
return (orderid: string) => {
|
||||
const stop = byOrder.get(orderid);
|
||||
if (!stop?.drop) return null;
|
||||
return {
|
||||
lat: stop.drop.lat,
|
||||
lng: stop.drop.lng,
|
||||
customer: stop.customer,
|
||||
address: stop.address,
|
||||
};
|
||||
};
|
||||
}, [mapped]);
|
||||
|
||||
const board = useMemo(() => activeBoard(riders, dropOf), [riders, dropOf]);
|
||||
|
||||
const pins: Pin[] = useMemo(() => {
|
||||
const out: Pin[] = [];
|
||||
board.forEach((entry, index) => {
|
||||
const { live: rider } = entry;
|
||||
if (rider.lat === null || rider.lng === null) return;
|
||||
out.push({
|
||||
id: `rider-${rider.userid}`,
|
||||
lat: rider.lat,
|
||||
lng: rider.lng,
|
||||
label: rider.name,
|
||||
lines: [
|
||||
`${rider.status}${rider.orderid ? ` · ${rider.orderid}` : ''}`,
|
||||
`Reported ${agoOf(rider.ageMs)}`,
|
||||
entry.toDropM !== null ? `${distanceLabel(entry.toDropM)} from the drop` : '',
|
||||
].filter(Boolean),
|
||||
colour: trailColour(index),
|
||||
// Only a fix from the last minute is drawn as a position. Everything
|
||||
// else is a last-known point and reads as a ring.
|
||||
isFaded: rider.freshness !== 'live',
|
||||
});
|
||||
const drop = dropOf(entry.orderid);
|
||||
if (drop) {
|
||||
out.push({
|
||||
id: `drop-${entry.orderid}`,
|
||||
lat: drop.lat,
|
||||
lng: drop.lng,
|
||||
label: entry.orderid,
|
||||
lines: [drop.customer || drop.address || 'Going here'],
|
||||
colour: '#10b981',
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [board, dropOf]);
|
||||
|
||||
const stale = board.filter((entry) => entry.live.freshness === 'old').length;
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Card padding={0}>
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Out right now
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{board.length === 0
|
||||
? 'nobody is carrying anything'
|
||||
: `${board.length} carrying · closest to their drop first`}
|
||||
{live.isFetching ? ' · updating…' : ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<TrailMap
|
||||
pins={pins}
|
||||
height={360}
|
||||
emptyNote={
|
||||
live.isLoading
|
||||
? 'Asking the riders where they are…'
|
||||
: userids.length === 0
|
||||
? 'Nothing is assigned, so nobody is out.'
|
||||
: 'No rider has reported a position.'
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{board.length > 0 ? (
|
||||
<div className="active-grid">
|
||||
{board.map((entry, index) => {
|
||||
const rider = entry.live;
|
||||
const worries = concerns(rider);
|
||||
return (
|
||||
<Card key={rider.userid} padding={0}>
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack justify="between" align="center" gap={1}>
|
||||
<span className="active-name">
|
||||
<i style={{ background: trailColour(index) }} />
|
||||
{rider.name}
|
||||
</span>
|
||||
<span className="active-fresh" data-fresh={rider.freshness}>
|
||||
{agoOf(rider.ageMs)}
|
||||
</span>
|
||||
</HStack>
|
||||
|
||||
<div className="active-distance">
|
||||
<Navigation size={15} />
|
||||
<strong>{distanceLabel(entry.toDropM)}</strong>
|
||||
<span>
|
||||
{entry.toDropM === null
|
||||
? 'position or drop unknown'
|
||||
: 'from the drop, in a straight line'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="active-meta">
|
||||
<span title="The order they are on">{entry.orderid || '—'}</span>
|
||||
<span>{entry.customer || entry.address || 'no address'}</span>
|
||||
</div>
|
||||
|
||||
<div className="active-stats">
|
||||
<span>{rider.status}</span>
|
||||
<span>{rider.speed === null ? '— km/h' : `${rider.speed.toFixed(0)} km/h`}</span>
|
||||
<span data-low={rider.battery !== null && rider.battery <= 15}>
|
||||
{rider.battery === null ? '—' : `${rider.battery}%`}
|
||||
{rider.isCharging ? ' ⚡' : ''}
|
||||
</span>
|
||||
<span>{rider.connection}</span>
|
||||
</div>
|
||||
|
||||
{worries.length > 0 ? (
|
||||
<div className="active-worry">
|
||||
{rider.connection === 'none' ? (
|
||||
<WifiOff size={13} />
|
||||
) : rider.battery !== null && rider.battery <= 15 ? (
|
||||
<BatteryLow size={13} />
|
||||
) : (
|
||||
<MapPin size={13} />
|
||||
)}
|
||||
{worries.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pva-note">
|
||||
<Info size={13} />
|
||||
<span>
|
||||
Positions come from the rider app and are shown with their age — a filled pin is a fix
|
||||
from the last minute, a ring is older. Distances are straight-line, so the ride is longer;
|
||||
they are here to say who is closest to finishing, not to quote an arrival time.
|
||||
{stale > 0
|
||||
? ` ${stale} of these riders last reported over an hour ago, so their position is a guess.`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Bike, Route, Truck, UserCheck } from 'lucide-react';
|
||||
import { Route, TriangleAlert, UserCheck } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import {
|
||||
RIDER_MESSAGE,
|
||||
@@ -13,7 +13,8 @@ import type { OrderRow, TenantLocation } from '@/api/types';
|
||||
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { buildDeliveries, riderName, riderScope, riderVehicle } from './assignDelivery';
|
||||
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
|
||||
import { hasNoDevice } from './riderReach';
|
||||
import { RoutePlanDrawer } from './RoutePlanDrawer';
|
||||
import './pages/deliveries.css';
|
||||
|
||||
@@ -35,11 +36,20 @@ export interface AssignBarProps {
|
||||
orders: OrderRow[];
|
||||
branchOf: (row: OrderRow) => TenantLocation | undefined;
|
||||
assigned: ReadonlySet<number>;
|
||||
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||
released?: ReadonlySet<number>;
|
||||
onClear: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) {
|
||||
export function AssignBar({
|
||||
orders,
|
||||
branchOf,
|
||||
assigned,
|
||||
released,
|
||||
onClear,
|
||||
onDone,
|
||||
}: AssignBarProps) {
|
||||
const client = useQueryClient();
|
||||
const [riderId, setRiderId] = useState('');
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
@@ -60,7 +70,19 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
const { tenantid } = useBranchScope();
|
||||
const shop = useOwnTenant(tenantid || undefined);
|
||||
const partnerid = Number((shop.data as unknown as Record<string, number>)?.['partnerid'] ?? 0);
|
||||
const [source, setSource] = useState<'own' | 'partner'>(partnerid > 0 ? 'partner' : 'own');
|
||||
/*
|
||||
Both fleets are read, and the picker lists them together.
|
||||
|
||||
This used to be an either/or toggle, so a shop with a partner had to switch
|
||||
back and forth to compare who was free — and the whole question at this
|
||||
moment is "who can take this", across everybody available. Two reads and one
|
||||
grouped list answers it in a glance instead.
|
||||
|
||||
Each rider still shows whose they are, because they are different people with
|
||||
different employers and an operator handing over a parcel should know which.
|
||||
*/
|
||||
const ownRiders = useRiders(tenantid ? { tenantid } : {});
|
||||
const partnerRiders = useRiders(partnerid ? { partnerid } : {});
|
||||
|
||||
// The partner's NAME on the tab, not "Partner riders". An operator handing an
|
||||
// order to Xpress-Cbe-Main should read that, not a category.
|
||||
@@ -76,14 +98,22 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
2026-09-09: 117 of the platform's 118 riders belong to a partner, so region
|
||||
scope was quietly offering other companies' fleets.
|
||||
*/
|
||||
const riders = useRiders(riderScope({ tenantid, partnerid, source }));
|
||||
const fleet = riders.data ?? [];
|
||||
const riders = { isLoading: ownRiders.isLoading || partnerRiders.isLoading };
|
||||
const fleet = useMemo(
|
||||
() => [...(ownRiders.data ?? []), ...(partnerRiders.data ?? [])],
|
||||
[ownRiders.data, partnerRiders.data],
|
||||
);
|
||||
/** Whose rider this is, for the label beside their name. */
|
||||
const ownIds = useMemo(
|
||||
() => new Set((ownRiders.data ?? []).map((entry) => entry.userid)),
|
||||
[ownRiders.data],
|
||||
);
|
||||
const rider = fleet.find((entry) => String(entry.userid) === riderId);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!rider) throw new Error('Pick a rider first');
|
||||
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned);
|
||||
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned, released);
|
||||
if (drafts.length === 0) {
|
||||
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
|
||||
}
|
||||
@@ -125,10 +155,24 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
onError: (error) => setOutcome(errorMessage(error)),
|
||||
});
|
||||
|
||||
const options = fleet.map((entry) => ({
|
||||
value: String(entry.userid),
|
||||
label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
|
||||
}));
|
||||
/*
|
||||
Whose rider, on every row.
|
||||
|
||||
The two fleets are one list now, so the label has to carry the employer —
|
||||
otherwise a shop with a partner reads eleven names and cannot tell which of
|
||||
them it pays. The vehicle stays because it is the other thing an operator
|
||||
picks on.
|
||||
*/
|
||||
const options = fleet.map((entry) => {
|
||||
const whose = ownIds.has(entry.userid) ? 'yours' : partnerName || 'partner';
|
||||
const vehicle = riderVehicle(entry);
|
||||
return {
|
||||
value: String(entry.userid),
|
||||
label: vehicle
|
||||
? `${riderName(entry)} · ${vehicle} · ${whose}`
|
||||
: `${riderName(entry)} · ${whose}`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
|
||||
@@ -137,37 +181,6 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
{orders.length} selected
|
||||
</span>
|
||||
|
||||
{/* Only when there is a choice to make. A shop with no partner has one
|
||||
source, and a toggle with one option is a control that asks a question
|
||||
it already knows the answer to. */}
|
||||
{partnerid > 0 ? (
|
||||
<div className="assign-source" role="group" aria-label="Which riders to offer">
|
||||
<button
|
||||
type="button"
|
||||
className="assign-source-btn"
|
||||
aria-pressed={source === 'own'}
|
||||
onClick={() => {
|
||||
setSource('own');
|
||||
setRiderId('');
|
||||
}}
|
||||
>
|
||||
<Bike size={13} />
|
||||
Own riders
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="assign-source-btn"
|
||||
aria-pressed={source === 'partner'}
|
||||
onClick={() => {
|
||||
setSource('partner');
|
||||
setRiderId('');
|
||||
}}
|
||||
>
|
||||
<Truck size={13} />
|
||||
{partnerName || 'Partner riders'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="assign-bar-picker">
|
||||
<Selector
|
||||
@@ -186,23 +199,41 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
|
||||
rather than being flattened to "Select…". "No riders on duty" is a
|
||||
shift that has not started; the region case is a branch somebody
|
||||
has to configure. Neither is a fault in the orders. */
|
||||
/* Four situations, and the placeholder names which one — an operator
|
||||
looking at an empty list needs to know whether to wait, to hire, or
|
||||
to ask for a partner. */
|
||||
/* An empty list has three different causes and the operator needs to
|
||||
know which: wait for somebody to clock on, hire a rider, or ask
|
||||
Nearle for a partner. Naming the fleets that were searched is what
|
||||
distinguishes them. */
|
||||
placeholder={
|
||||
riders.isLoading
|
||||
? 'Loading riders…'
|
||||
: options.length > 0
|
||||
? 'Select a rider…'
|
||||
: source === 'partner'
|
||||
? `No ${partnerName || 'partner'} rider has clocked on today`
|
||||
: partnerid > 0
|
||||
? 'This shop has no riders of its own'
|
||||
: 'No rider has clocked on today'
|
||||
: partnerid > 0
|
||||
? 'No riders or partners on duty'
|
||||
: 'No riders on duty (no partner)'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Said before the hand-off, not after it.
|
||||
|
||||
A rider who has never opened the app has no device to push to, so the
|
||||
job lands in a queue nobody is told about. That used to surface as
|
||||
"NOT notified" in the outcome line AFTER the deliveries were written —
|
||||
by which point the only remedy is a phone call, and one navigation
|
||||
later there was no record of it at all.
|
||||
|
||||
It disables nothing. Assigning to a rider you are about to ring is a
|
||||
legitimate thing to do; being surprised by it afterwards is not.
|
||||
*/}
|
||||
{rider && hasNoDevice(rider) ? (
|
||||
<span className="assign-bar-warn" role="status">
|
||||
<TriangleAlert size={14} />
|
||||
No app on this rider’s phone yet — they will not be told. Call them.
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Two ways to commit the same selection, and the difference is worth the
|
||||
second button: "Assign" hands the orders over as they are, which is
|
||||
right for one or two. "Plan the route" sequences them first — real
|
||||
|
||||
362
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
362
src/features/store-admin/AutoAssignDrawer.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { AlertTriangle, Bike, Sparkles, UserMinus } from 'lucide-react';
|
||||
import { optimiserApi, OptimiserError } from '@/api/optimiser';
|
||||
import { deliveriesApi, RIDER_MESSAGE, RiderNotReachableError } from '@/api/deliveries';
|
||||
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { Drawer } from './Drawer';
|
||||
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit';
|
||||
import { buildDeliveries } from './assignDelivery';
|
||||
import {
|
||||
absentFrom,
|
||||
branchFor,
|
||||
buildRequest,
|
||||
committable,
|
||||
readPlan,
|
||||
TUNINGS,
|
||||
unmatched,
|
||||
type Plan,
|
||||
type Tuning,
|
||||
} from './autoAssign';
|
||||
import { moneyExact } from './format';
|
||||
import './dispatchPanels.css';
|
||||
|
||||
/**
|
||||
* Let the optimiser propose the round, then commit it here.
|
||||
*
|
||||
* ── Two steps, deliberately ─────────────────────────────────────────────────
|
||||
*
|
||||
* Running the solver writes nothing. It answers with a PROPOSAL, the operator
|
||||
* reads it, and only "Assign" turns it into deliveries. That separation is the
|
||||
* whole design: a solver that assigned directly would be a button that
|
||||
* dispatches every waiting order to riders nobody has looked at, and the first
|
||||
* time it got a round wrong there would be no moment at which anyone could have
|
||||
* caught it.
|
||||
*
|
||||
* ── Committed through the same door as the manual bar ───────────────────────
|
||||
*
|
||||
* `buildDeliveries` → `deliveriesApi.assign`, exactly as `AssignBar` does. Not
|
||||
* a shortcut: `assignDelivery` is where the knowledge lives about which ids a
|
||||
* delivery must carry to be joinable afterwards, and a second write path here
|
||||
* would be a second place for that to drift. The solver decides WHO; it does
|
||||
* not get to decide what a delivery row looks like.
|
||||
*
|
||||
* One call for the whole plan, because one call is one transaction — several
|
||||
* riders' rows in a single array is what the endpoint takes and what the old
|
||||
* console sends.
|
||||
*/
|
||||
export function AutoAssignDrawer({
|
||||
orders,
|
||||
branches,
|
||||
fleet,
|
||||
assigned,
|
||||
released,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** The waiting orders this run is for. */
|
||||
orders: readonly OrderRow[];
|
||||
branches: readonly TenantLocation[];
|
||||
/** Riders we know about, for the absentee picker and for naming. */
|
||||
fleet: readonly RiderInfo[];
|
||||
assigned: ReadonlySet<number>;
|
||||
/** Orders whose delivery is dead — see `releasedFrom`. */
|
||||
released?: ReadonlySet<number>;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const [tuning, setTuning] = useState<Tuning>('balanced');
|
||||
const [absent, setAbsent] = useState<ReadonlySet<number>>(new Set());
|
||||
const [plan, setPlan] = useState<Plan | null>(null);
|
||||
const [outcome, setOutcome] = useState<string | null>(null);
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
|
||||
const branchOf = useMemo(
|
||||
() => (row: OrderRow) => branchFor(row, branches),
|
||||
[branches],
|
||||
);
|
||||
|
||||
const solve = useMutation({
|
||||
mutationFn: async () => {
|
||||
abort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
const away = fleet.filter((rider) => absent.has(rider.userid)).map(absentFrom);
|
||||
const response = await optimiserApi.assign(
|
||||
buildRequest(orders, away),
|
||||
tuning,
|
||||
controller.signal,
|
||||
);
|
||||
return readPlan(response, orders);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
setPlan(next);
|
||||
setOutcome(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setPlan(null);
|
||||
setOutcome(
|
||||
error instanceof OptimiserError ? error.message : 'The optimiser run failed.',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const commit = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!plan) throw new Error('Run the optimiser first');
|
||||
/* Every proposal's rows, built through the same path the manual bar uses
|
||||
and sent as one array — one call is one transaction. */
|
||||
const drafts = plan.proposals.flatMap((proposal) => {
|
||||
const rows = committable(proposal);
|
||||
// Only `userid` is read off the rider when a delivery is built; the
|
||||
// rest of the roster row matters for notifying, which happens after.
|
||||
const rider = (fleet.find((r) => r.userid === proposal.userid) ??
|
||||
({ userid: proposal.userid } as RiderInfo));
|
||||
return buildDeliveries(rows, rider, branchOf, new Date(), assigned, released).drafts;
|
||||
});
|
||||
|
||||
if (drafts.length === 0) {
|
||||
throw new Error('Nothing in this plan can be assigned.');
|
||||
}
|
||||
await deliveriesApi.assign(drafts);
|
||||
return drafts.length;
|
||||
},
|
||||
onSuccess: async (count) => {
|
||||
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
|
||||
setOutcome(`${count} order${count === 1 ? '' : 's'} assigned · telling the riders…`);
|
||||
onDone();
|
||||
|
||||
/* Notified after the write and reported separately: the deliveries exist
|
||||
either way, so a failed push must not read as a failed assignment —
|
||||
but it must still be visible, because a rider who was never told has
|
||||
work sitting unseen. */
|
||||
const failures: string[] = [];
|
||||
for (const proposal of plan?.proposals ?? []) {
|
||||
const rider = fleet.find((r) => r.userid === proposal.userid);
|
||||
try {
|
||||
await deliveriesApi.notify(
|
||||
rider?.userfcmtoken ?? '',
|
||||
RIDER_MESSAGE.assigned(proposal.stops.length),
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
error instanceof RiderNotReachableError
|
||||
? `${proposal.rider} has no device registered`
|
||||
: `${proposal.rider} could not be reached`,
|
||||
);
|
||||
}
|
||||
}
|
||||
setOutcome(
|
||||
failures.length === 0
|
||||
? `${count} order${count === 1 ? '' : 's'} assigned · every rider notified`
|
||||
: `${count} assigned · NOT notified: ${failures.join('; ')} — tell them another way`,
|
||||
);
|
||||
setPlan(null);
|
||||
},
|
||||
onError: (error) =>
|
||||
setOutcome(error instanceof Error ? error.message : 'The assignment failed.'),
|
||||
});
|
||||
|
||||
const isRunning = solve.isPending;
|
||||
const missing = plan ? unmatched(plan) : [];
|
||||
const canCommit = Boolean(plan && plan.proposals.length > 0 && !commit.isPending);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Auto-assign"
|
||||
subtitle={`${orders.length} order${orders.length === 1 ? '' : 's'} waiting`}
|
||||
width={620}
|
||||
onClose={onClose}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton
|
||||
label={isRunning ? 'Cancel' : 'Close'}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isRunning) abort.current?.abort();
|
||||
else onClose();
|
||||
}}
|
||||
/>
|
||||
{plan ? (
|
||||
<DrawerButton
|
||||
label={commit.isPending ? 'Assigning…' : `Assign ${plan.meta.assigned}`}
|
||||
variant="primary"
|
||||
icon={<Bike size={15} />}
|
||||
isDisabled={!canCommit}
|
||||
onClick={() => commit.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<DrawerButton
|
||||
label={isRunning ? 'Working…' : 'Run the optimiser'}
|
||||
variant="primary"
|
||||
icon={<Sparkles size={15} />}
|
||||
isDisabled={isRunning || orders.length === 0}
|
||||
onClick={() => solve.mutate()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<VStack gap={2}>
|
||||
<Note icon={<Sparkles size={15} />}>
|
||||
The optimiser proposes who carries what. Nothing is assigned until you press Assign, so
|
||||
running it is always safe — and running it again just replaces the proposal.
|
||||
</Note>
|
||||
|
||||
{/* ── Before the run ──────────────────────────────────────────── */}
|
||||
{!plan ? (
|
||||
<>
|
||||
<Section title="How to solve it">
|
||||
<div className="aa-tunings">
|
||||
{TUNINGS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="aa-tuning"
|
||||
data-active={tuning === option.id}
|
||||
disabled={isRunning}
|
||||
onClick={() => setTuning(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Anybody off today?">
|
||||
<DrawerCard>
|
||||
<Row
|
||||
label="Absent riders"
|
||||
value="Ticked riders are left out of the plan. Leaving this empty is normal — the solver then considers everyone."
|
||||
isStacked
|
||||
/>
|
||||
{fleet.length === 0 ? (
|
||||
<Row
|
||||
label="Fleet"
|
||||
value="No riders are on duty, so there is nobody to mark absent."
|
||||
isStacked
|
||||
/>
|
||||
) : (
|
||||
<div className="aa-absent">
|
||||
{fleet.map((rider) => {
|
||||
const name =
|
||||
`${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() ||
|
||||
rider.fullname?.trim() ||
|
||||
`Rider ${rider.userid}`;
|
||||
return (
|
||||
<label key={rider.userid} className="aa-absent-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={absent.has(rider.userid)}
|
||||
disabled={isRunning}
|
||||
onChange={() =>
|
||||
setAbsent((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rider.userid)) next.delete(rider.userid);
|
||||
else next.add(rider.userid);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<UserMinus size={13} />
|
||||
{name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ── The proposal ────────────────────────────────────────────── */}
|
||||
{plan?.blocked ? (
|
||||
<div className="aa-blocked">
|
||||
<AlertTriangle size={16} />
|
||||
<div>
|
||||
<strong>The optimiser could not consider anybody</strong>
|
||||
<span>{plan.blocked}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plan && plan.proposals.length > 0 ? (
|
||||
<Section
|
||||
title={`${plan.meta.assigned} of ${plan.meta.totalOrders} placed with ${plan.proposals.length} rider${plan.proposals.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
{plan.proposals.map((proposal) => (
|
||||
<DrawerCard key={proposal.userid}>
|
||||
<Row
|
||||
label={proposal.rider}
|
||||
value={
|
||||
<Badge
|
||||
label={`${proposal.stops.length} stop${proposal.stops.length === 1 ? '' : 's'}`}
|
||||
colour="var(--color-brand)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{proposal.stops.map((stop) => (
|
||||
<Row
|
||||
key={stop.orderid || stop.orderheaderid}
|
||||
label={stop.orderid || `#${stop.orderheaderid}`}
|
||||
value={
|
||||
stop.row
|
||||
? moneyExact(
|
||||
stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0,
|
||||
)
|
||||
: 'not in this list'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DrawerCard>
|
||||
))}
|
||||
{plan.meta.profit > 0 ? (
|
||||
<Note>
|
||||
The optimiser puts this plan at {moneyExact(plan.meta.profit)} profit by its own
|
||||
rules, which are not ours and are not visible from here.
|
||||
</Note>
|
||||
) : null}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{missing.length > 0 ? (
|
||||
<Note icon={<AlertTriangle size={15} />}>
|
||||
{missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched
|
||||
back to this list and will not be assigned: {missing.join(', ')}.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
{plan && plan.unassigned.length > 0 ? (
|
||||
<Section title={`${plan.unassigned.length} left unassigned`}>
|
||||
<DrawerCard>
|
||||
{plan.unassigned.slice(0, 12).map((entry) => (
|
||||
<Row key={entry.orderid} label={entry.orderid} value={entry.reason} isStacked />
|
||||
))}
|
||||
{plan.unassigned.length > 12 ? (
|
||||
<Row
|
||||
label="…"
|
||||
value={`and ${plan.unassigned.length - 12} more, for the same reasons`}
|
||||
/>
|
||||
) : null}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{outcome ? (
|
||||
<HStack gap={1} align="center">
|
||||
<Text type="body" size="sm">
|
||||
{outcome}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
261
src/features/store-admin/BranchScope.dom.test.tsx
Normal file
261
src/features/store-admin/BranchScope.dom.test.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* The branch filter, mounted for real.
|
||||
*
|
||||
* ── Why this test exists ────────────────────────────────────────────────────
|
||||
*
|
||||
* The filter silently reset to "All branches" on every navigation, and nothing
|
||||
* in the codebase could see it. The selection was derived straight from the
|
||||
* `?branch=` search param, and every nav link in `AppShell` is a bare path
|
||||
* (`to="/admin/sales"`), so React Router replaced the whole location and the
|
||||
* param went with it. An absent param read as All.
|
||||
*
|
||||
* No pure-function test could catch that: the bug only exists in the
|
||||
* interaction between the provider, the router and a link that drops the query
|
||||
* string. So this mounts the provider inside a real router, navigates the way
|
||||
* the shell does, and asserts the selection survives.
|
||||
*
|
||||
* The assertions below are mostly about what must NOT change: a nav click must
|
||||
* not widen the operator's scope, and the URL must still be able to set it, or
|
||||
* a shared link to one shop stops working.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { TenantLocation } from '@/api/types';
|
||||
|
||||
const BRANCHES = [
|
||||
{ locationid: 1097, tenantid: 1087, locationname: 'Ragul stores', status: 'Active' },
|
||||
{ locationid: 1135, tenantid: 1087, locationname: 'Ragul Selvapuram', status: 'Active' },
|
||||
{ locationid: 1138, tenantid: 1087, locationname: 'Deborah Lara', status: 'Active' },
|
||||
] as TenantLocation[];
|
||||
|
||||
interface Harness {
|
||||
/** What the provider currently reports. */
|
||||
read: () => { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean };
|
||||
/** Pick a branch through the provider's own `select`. */
|
||||
pick: (next: number | null) => Promise<void>;
|
||||
/** Navigate the way `AppShell` does — a bare path, no query string. */
|
||||
navigate: (to: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let mount: (opts: { url: string; pin?: number }) => Promise<Harness>;
|
||||
|
||||
/* Everything a mount creates, so it can be torn down. Without this the file
|
||||
never exits: each mount leaves a live QueryClient with an active observer,
|
||||
and node:test waits on the open handles until the suite times out — every
|
||||
test passing and the FILE reported as failed. */
|
||||
const created: { unmount: () => void }[] = [];
|
||||
let closeDom: () => void = () => {};
|
||||
|
||||
before(async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
url: 'http://localhost/admin/console',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement', 'Event',
|
||||
'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame',
|
||||
'localStorage', 'sessionStorage', 'MouseEvent', 'CustomEvent',
|
||||
]) g[key] = win[key];
|
||||
Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true });
|
||||
g['ResizeObserver'] = class { observe() {} unobserve() {} disconnect() {} };
|
||||
|
||||
/* No network. Without this the test is not hermetic and it silently was not:
|
||||
`useTenantLocations` fetched the REAL tenant 1087 from fiesta.nearle.app and
|
||||
replaced the three seeded branches with the six that shop actually has, so
|
||||
two assertions failed against production data that has nothing to do with
|
||||
what is under test — and would fail differently the day someone opens a
|
||||
seventh outlet. */
|
||||
g['fetch'] = () => Promise.reject(new Error('no network in tests'));
|
||||
|
||||
const React = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom');
|
||||
const { BranchScopeProvider, useBranchScope } = await import('./BranchScope');
|
||||
|
||||
/* The branch list, without the network. `useTenantLocations` is a TanStack
|
||||
hook; stubbing the module would mean stubbing the query client too, so the
|
||||
provider is given a real one whose fetch resolves immediately. */
|
||||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||||
const { AuthContext } = await import('@/auth/context');
|
||||
|
||||
closeDom = () => dom.window.close();
|
||||
|
||||
mount = async ({ url, pin }) => {
|
||||
const host = dom.window.document.createElement('div');
|
||||
dom.window.document.body.appendChild(host);
|
||||
|
||||
const qc = new QueryClient({
|
||||
// No gcTime: 0 here. It collects the seeded entry before any component has
|
||||
// subscribed to it, so the provider saw an empty branch list and every
|
||||
// assertion read null.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
// Seed the cache under the key `useTenantLocations` reads, so the provider
|
||||
// sees a loaded branch list on first render.
|
||||
const { queryKeys } = await import('@/queries/keys');
|
||||
qc.setQueryData(queryKeys.tenants.locations(1087), BRANCHES);
|
||||
|
||||
let api: { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean } | null = null;
|
||||
let doSelect: ((n: number | null) => void) | null = null;
|
||||
let doNavigate: ((to: string) => void) | null = null;
|
||||
|
||||
function Probe() {
|
||||
const scope = useBranchScope();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
api = {
|
||||
selected: scope.selected,
|
||||
scopedCount: scope.scoped.length,
|
||||
search: location.search,
|
||||
branchCount: scope.branches.length,
|
||||
isLoading: scope.isLoading,
|
||||
};
|
||||
doSelect = scope.select;
|
||||
doNavigate = (to) => navigate(to);
|
||||
return null;
|
||||
}
|
||||
|
||||
const auth = {
|
||||
user: {
|
||||
userid: 1, role: 'store-admin', name: 'A', email: 'a@b.c',
|
||||
roleid: 1, tenantid: 1087, locationid: 0, issuperadmin: false,
|
||||
},
|
||||
isLoading: false,
|
||||
signIn: async () => { throw new Error('not used'); },
|
||||
signOut: () => {},
|
||||
};
|
||||
|
||||
const root = createRoot(host);
|
||||
created.push({ unmount: () => { root.unmount(); qc.unmount(); qc.clear(); } });
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider, { client: qc },
|
||||
React.createElement(
|
||||
AuthContext.Provider, { value: auth as never },
|
||||
React.createElement(
|
||||
MemoryRouter, { initialEntries: [url] },
|
||||
React.createElement(
|
||||
Routes, null,
|
||||
React.createElement(Route, {
|
||||
path: '/admin/*',
|
||||
// children passed in the props object, not as a third argument:
|
||||
// BranchScopeProvider declares children as required, and
|
||||
// createElement's overload will not accept a null props object.
|
||||
element: React.createElement(BranchScopeProvider, {
|
||||
pin,
|
||||
children: React.createElement(Probe),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 60));
|
||||
await settle();
|
||||
|
||||
return {
|
||||
read: () => api!,
|
||||
pick: async (next) => { doSelect!(next); await settle(); },
|
||||
navigate: async (to) => { doNavigate!(to); await settle(); },
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
/* ── The bug ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// THE regression test. A nav click replaces the whole location, query string
|
||||
// included; that must not change which shop the operator is looking at.
|
||||
test('a nav click does not reset the chosen branch', async () => {
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
await h.pick(1135);
|
||||
assert.equal(h.read().selected, 1135);
|
||||
|
||||
await h.navigate('/admin/sales');
|
||||
assert.equal(h.read().selected, 1135, 'navigating to Sales widened the scope back to All');
|
||||
|
||||
await h.navigate('/admin/inventory');
|
||||
assert.equal(h.read().selected, 1135, 'the reset appeared on the second hop');
|
||||
});
|
||||
|
||||
// Scope is what pages actually read, so it has to narrow with the selection —
|
||||
// a label that changes while every page keeps reading all six is the same bug
|
||||
// wearing a different hat.
|
||||
test('the scope pages read narrows to the one branch, and survives too', async () => {
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
assert.equal(h.read().scopedCount, 3, 'All branches should scope to every outlet');
|
||||
|
||||
await h.pick(1138);
|
||||
assert.equal(h.read().scopedCount, 1);
|
||||
|
||||
await h.navigate('/admin/reports');
|
||||
assert.equal(h.read().scopedCount, 1, 'scope widened on navigation');
|
||||
});
|
||||
|
||||
// The URL is a mirror, and it has to be put back after a nav click dropped it,
|
||||
// or the address bar quietly disagrees with the control.
|
||||
test('the param is written back after navigation drops it', async () => {
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
await h.pick(1135);
|
||||
assert.match(h.read().search, /branch=1135/);
|
||||
|
||||
await h.navigate('/admin/sales');
|
||||
assert.match(h.read().search, /branch=1135/, 'the URL lost the branch it is scoped to');
|
||||
});
|
||||
|
||||
/* ── What must keep working ──────────────────────────────────────────────── */
|
||||
|
||||
// The reason the param existed in the first place: a link to one shop's
|
||||
// inventory has to survive being pasted into a chat.
|
||||
test('an explicit param in the URL still sets the branch', async () => {
|
||||
const h = await mount({ url: '/admin/inventory?branch=1138' });
|
||||
assert.equal(h.read().selected, 1138);
|
||||
});
|
||||
|
||||
// The id is user-editable, so it is untrusted. An outlet this tenant does not
|
||||
// own falls back to All rather than rendering an empty page.
|
||||
test('an id this tenant does not own falls back to All', async () => {
|
||||
const h = await mount({ url: '/admin/console?branch=999999' });
|
||||
assert.equal(h.read().selected, null);
|
||||
assert.equal(h.read().scopedCount, 3);
|
||||
});
|
||||
|
||||
test('branch=all is honoured, and switching back to All works', async () => {
|
||||
const explicit = await mount({ url: '/admin/console?branch=all' });
|
||||
assert.equal(explicit.read().selected, null);
|
||||
|
||||
const h = await mount({ url: '/admin/console' });
|
||||
await h.pick(1097);
|
||||
assert.equal(h.read().selected, 1097);
|
||||
await h.pick(null);
|
||||
assert.equal(h.read().selected, null, 'could not get back to All branches');
|
||||
assert.doesNotMatch(h.read().search, /branch=/, 'the param should be removed under All');
|
||||
});
|
||||
|
||||
/* ── The pinned workspace ────────────────────────────────────────────────── */
|
||||
|
||||
// Fiesta authorises a POS or catalogue read on locationid alone, so for a store
|
||||
// user the URL param is not a filter — it is the authorisation boundary. The
|
||||
// pin has to win over anything in the address bar.
|
||||
test('a pinned branch ignores the URL and cannot be selected away from', async () => {
|
||||
const h = await mount({ url: '/admin/console?branch=1138', pin: 1097 });
|
||||
assert.equal(h.read().selected, 1097, 'the URL overrode the session pin');
|
||||
assert.equal(h.read().scopedCount, 1);
|
||||
|
||||
await h.pick(1135);
|
||||
assert.equal(h.read().selected, 1097, 'select() moved a pinned scope');
|
||||
|
||||
await h.navigate('/admin/sales');
|
||||
assert.equal(h.read().selected, 1097);
|
||||
});
|
||||
|
||||
/* ── Teardown ────────────────────────────────────────────────────────────── */
|
||||
|
||||
after(() => {
|
||||
for (const c of created) c.unmount();
|
||||
closeDom();
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTenantLocations } from '@/queries/hooks';
|
||||
@@ -32,16 +39,18 @@ export interface BranchScopeValue {
|
||||
|
||||
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
|
||||
|
||||
/** The URL param. In the URL so a link to a page carries its branch with it. */
|
||||
/** The URL param. Mirrors the selection so a link carries its branch with it. */
|
||||
const PARAM = 'branch';
|
||||
|
||||
/**
|
||||
* Which branch the Store Admin is looking at.
|
||||
*
|
||||
* Held in the URL rather than in component state for two reasons. A link to
|
||||
* "Inventory, Peelamedu" has to survive being pasted into a chat, and a reload
|
||||
* during a shift must not silently drop the operator back to All branches while
|
||||
* they are reading a number that only makes sense for one shop.
|
||||
* Held in component state, mirrored to the URL. The mirror is what lets a link
|
||||
* to "Inventory, Peelamedu" survive being pasted into a chat; holding the state
|
||||
* here rather than reading it back out of the address bar is what stops a nav
|
||||
* click — which replaces the query string — from resetting the operator to All
|
||||
* branches mid-shift while they read a number that only means anything for one
|
||||
* shop. See the long note on `chosen` below.
|
||||
*
|
||||
* The tenant, by contrast, comes from the session and is deliberately NOT in
|
||||
* the URL. Fiesta has no web auth, so tenant scoping is enforced by this client
|
||||
@@ -83,15 +92,68 @@ export function BranchScopeProvider({
|
||||
);
|
||||
|
||||
const raw = params.get(PARAM);
|
||||
const parsed = raw === null || raw === 'all' ? null : Number(raw);
|
||||
// An id in the URL that this tenant does not own falls back to All rather
|
||||
// than showing an empty page — the id is user-editable, so it is untrusted.
|
||||
const selected =
|
||||
pin !== undefined
|
||||
? pin
|
||||
: parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
/*
|
||||
The selection lives here, and the URL only mirrors it.
|
||||
|
||||
── The bug this fixes ──────────────────────────────────────────────────────
|
||||
|
||||
It used to be derived straight from the search param, with an absent param
|
||||
meaning All branches. That is wrong, because absent does not mean "show me
|
||||
everything" — it mostly means "you just clicked a nav tab". Every link in
|
||||
`AppShell` is a bare path (`to="/admin/sales"`), so React Router replaces the
|
||||
whole location, query string included, and the param is simply gone. The
|
||||
branch filter therefore reset to All on every navigation: pick a shop on
|
||||
Console, click Sales, and you were back to all six with nothing saying so.
|
||||
Reproduced on tenant 1087 (Ragul Stores, 6 branches) — the label went from
|
||||
"Ragul stores Selvapuram" back to "All branches (6)".
|
||||
|
||||
Copying the whole search string onto the nav links would have fixed it and
|
||||
broken something else: `InventoryPage` and the global catalogue keep their own
|
||||
params, and those would then follow the operator from page to page.
|
||||
|
||||
── The rule ────────────────────────────────────────────────────────────────
|
||||
|
||||
A param that is PRESENT is obeyed, so a link to "Inventory, Peelamedu" still
|
||||
survives being pasted into a chat, and editing the id in the address bar still
|
||||
works. A param that is ABSENT changes nothing, so navigation cannot silently
|
||||
widen the operator's scope. The effect below then writes the param back, which
|
||||
is what keeps the URL honest after a nav click.
|
||||
*/
|
||||
const [chosen, setChosen] = useState<BranchSelection>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pin !== undefined || raw === null) return;
|
||||
if (raw === 'all') {
|
||||
setChosen(null);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)) {
|
||||
setChosen(parsed);
|
||||
} else if (!isLoading) {
|
||||
// An id this tenant does not own falls back to All rather than showing an
|
||||
// empty page — the id is user-editable, so it is untrusted. Guarded on
|
||||
// `isLoading` because `branches` is empty until the fetch lands, and
|
||||
// resetting then would throw away a perfectly good deep link.
|
||||
setChosen(null);
|
||||
}
|
||||
}, [raw, branches, isLoading, pin]);
|
||||
|
||||
const selected = pin !== undefined ? pin : chosen;
|
||||
|
||||
// The URL follows the selection, including putting the param back after a nav
|
||||
// click has dropped it. Built from the current params so a page's own query
|
||||
// state is carried through untouched.
|
||||
useEffect(() => {
|
||||
if (pin !== undefined) return;
|
||||
const want = selected === null ? null : String(selected);
|
||||
if ((params.get(PARAM) ?? null) === want) return;
|
||||
const next = new URLSearchParams(params);
|
||||
if (want === null) next.delete(PARAM);
|
||||
else next.set(PARAM, want);
|
||||
setParams(next, { replace: true });
|
||||
}, [selected, params, setParams, pin]);
|
||||
|
||||
const value = useMemo<BranchScopeValue>(() => {
|
||||
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
||||
@@ -103,15 +165,14 @@ export function BranchScopeProvider({
|
||||
current,
|
||||
isPinned: pin !== undefined,
|
||||
scoped: current ? [current] : branches,
|
||||
// State only. The URL is updated by the mirroring effect above, so there
|
||||
// is one place that writes the param rather than two that can disagree.
|
||||
select: (next) => {
|
||||
if (pin !== undefined) return;
|
||||
const nextParams = new URLSearchParams(params);
|
||||
if (next === null) nextParams.delete(PARAM);
|
||||
else nextParams.set(PARAM, String(next));
|
||||
setParams(nextParams, { replace: true });
|
||||
setChosen(next);
|
||||
},
|
||||
};
|
||||
}, [branches, isLoading, tenantid, selected, params, setParams, pin]);
|
||||
}, [branches, isLoading, tenantid, selected, pin]);
|
||||
|
||||
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { EyeOff } from 'lucide-react';
|
||||
import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
Badge,
|
||||
DrawerButton,
|
||||
DrawerCard,
|
||||
Field,
|
||||
Metric,
|
||||
Metrics,
|
||||
Note,
|
||||
Row,
|
||||
Section,
|
||||
TextField,
|
||||
} from './drawerKit';
|
||||
import type { CounterLabels } from './counterLabels';
|
||||
import type { CounterRow } from './counterRows';
|
||||
import { COUNTER_STATE_COLOUR, COUNTER_STATE_LABEL, type CounterRow } from './counterRows';
|
||||
import { money } from './format';
|
||||
import { shortAge } from './posStatus';
|
||||
|
||||
@@ -21,6 +28,19 @@ import { shortAge } from './posStatus';
|
||||
* Rename and hide live here rather than on the row. They are rare actions, and
|
||||
* a control on every row of a fifteen-row table is fifteen chances to hide a
|
||||
* counter by mistake.
|
||||
*
|
||||
* ── What this used to get wrong ─────────────────────────────────────────────
|
||||
*
|
||||
* The table's Status column — Active, Attention, Offline, Never used — was the
|
||||
* one reading this drawer did not repeat, so the chip that made somebody open a
|
||||
* counter vanished the moment they did. It is in the header now, from the same
|
||||
* `state` the row chips from, and the named problem behind an "Attention" is
|
||||
* stated rather than left as a colour.
|
||||
*
|
||||
* The readings were also drawn by a `.reading-list` belonging to the counters
|
||||
* page stylesheet, which is why this drawer had a different row height, label
|
||||
* weight and hairline from every other drawer in the console. It is built from
|
||||
* the kit now, like the rest.
|
||||
*/
|
||||
export interface CounterDrawerProps {
|
||||
row: CounterRow;
|
||||
@@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
|
||||
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
|
||||
);
|
||||
const status = row.status;
|
||||
|
||||
const readings: [string, string][] = [
|
||||
['Counter code', row.terminalId],
|
||||
['Branch', row.branchName],
|
||||
['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`],
|
||||
];
|
||||
|
||||
if (status) {
|
||||
readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]);
|
||||
readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']);
|
||||
readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]);
|
||||
// The number that explains a revenue figure reading low, so it is stated
|
||||
// even when it is zero rather than hidden as "nothing to report".
|
||||
readings.push(['Waiting to send', String(status.pendingBills)]);
|
||||
if (status.reason) readings.push(['Reported', status.reason]);
|
||||
} else {
|
||||
readings.push(['Status', 'This counter has never reported in.']);
|
||||
}
|
||||
const isNamed = labels.isNamed(row.terminalId);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId}
|
||||
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName}
|
||||
title={isNamed ? labels.nameFor(row.terminalId) : row.terminalId}
|
||||
{...(isNamed ? {} : { isTitleMono: true })}
|
||||
subtitle={isNamed ? row.terminalId : row.branchName}
|
||||
width={460}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
<div className="reading-list">
|
||||
{readings.map(([label, value]) => (
|
||||
<div key={label} className="reading">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<TextInput
|
||||
label="Name this counter"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder={row.terminalId}
|
||||
description="Shown instead of the code. The code stays on the receipts."
|
||||
/* The same chip the row wears, so the reading that made somebody open
|
||||
this counter is still on screen once they have. */
|
||||
meta={
|
||||
<>
|
||||
<Badge label={COUNTER_STATE_LABEL[row.state]} colour={COUNTER_STATE_COLOUR[row.state]} />
|
||||
{isNamed ? <span className="drawer-meta-text">{row.branchName}</span> : null}
|
||||
</>
|
||||
}
|
||||
isFooterSpread
|
||||
footer={
|
||||
<>
|
||||
<DrawerButton
|
||||
label="Hide this counter"
|
||||
variant="ghost"
|
||||
icon={<EyeOff size={14} />}
|
||||
onClick={() => {
|
||||
labels.hide(row.terminalId);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button
|
||||
label="Hide this counter"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<EyeOff size={14} />}
|
||||
onClick={() => {
|
||||
labels.hide(row.terminalId);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save name"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
|
||||
onClick={() => {
|
||||
labels.rename(row.terminalId, draft.trim());
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Hiding removes a counter from this page. It keeps recording sales — use it for a till
|
||||
that has been retired.
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
<DrawerButton
|
||||
label="Save name"
|
||||
variant="primary"
|
||||
isDisabled={draft.trim() === '' && !isNamed}
|
||||
onClick={() => {
|
||||
labels.rename(row.terminalId, draft.trim());
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* ── The period, in the figures the table totals ──────────────────── */}
|
||||
<Metrics cols={2}>
|
||||
<Metric
|
||||
label="Sold in this period"
|
||||
value={money(row.periodAmount)}
|
||||
note={`${row.periodBills} bill${row.periodBills === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<Metric
|
||||
label="Today on the till"
|
||||
value={status ? money(status.todayAmount) : '—'}
|
||||
note={status ? `${status.todayBills} bill${status.todayBills === 1 ? '' : 's'}` : 'never reported'}
|
||||
isSmall
|
||||
/>
|
||||
</Metrics>
|
||||
|
||||
{/* The named problem behind an "Attention" or an "Offline". The board
|
||||
already worked out what is wrong and how long it has been wrong; the
|
||||
row could only show the colour. */}
|
||||
{row.card ? (
|
||||
<Note icon={<AlertTriangle size={15} />}>
|
||||
<strong>{row.card.problem.headline}</strong> {row.card.problem.detail}
|
||||
{row.card.problem.action ? (
|
||||
<>
|
||||
{' '}
|
||||
<strong>{row.card.problem.action}</strong>
|
||||
</>
|
||||
) : null}
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
<Section title="Readings">
|
||||
<DrawerCard>
|
||||
<Row label="Counter code" value={row.terminalId} icon={<Hash size={14} />} />
|
||||
<Row label="Branch" value={row.branchName} icon={<MapPin size={14} />} />
|
||||
{status ? (
|
||||
<>
|
||||
<Row
|
||||
label="Last sale"
|
||||
value={
|
||||
status.lastBillAt
|
||||
? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago`
|
||||
: 'never'
|
||||
}
|
||||
icon={<Receipt size={14} />}
|
||||
{...(status.lastBillAt ? {} : { tone: 'muted' as const })}
|
||||
/>
|
||||
<Row
|
||||
label="Last heard from"
|
||||
value={status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`}
|
||||
icon={<Monitor size={14} />}
|
||||
{...(status.silentForMs == null ? { tone: 'muted' as const } : {})}
|
||||
/>
|
||||
{/* Stated even at zero. It is the number that explains a revenue
|
||||
figure reading low, and hiding it as "nothing to report" is
|
||||
what leaves somebody hunting for the missing money. */}
|
||||
<Row
|
||||
label="Waiting to send"
|
||||
value={String(status.pendingBills)}
|
||||
{...(status.pendingBills > 0 ? {} : { tone: 'muted' as const })}
|
||||
/>
|
||||
{status.reason ? <Row label="Reported" value={status.reason} isStacked /> : null}
|
||||
</>
|
||||
) : (
|
||||
<Row label="Status" value="This counter has never reported in." isStacked />
|
||||
)}
|
||||
</DrawerCard>
|
||||
</Section>
|
||||
|
||||
<Section title="Name and visibility">
|
||||
<Field
|
||||
label="Name this counter"
|
||||
description="Shown instead of the code. The code stays on the receipts."
|
||||
>
|
||||
<TextField value={draft} onChange={setDraft} placeholder={row.terminalId} />
|
||||
</Field>
|
||||
<Note>
|
||||
Hiding removes a counter from this page. It keeps recording sales — use it for a till that
|
||||
has been retired.
|
||||
</Note>
|
||||
</Section>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import './dateRangePicker.css';
|
||||
|
||||
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
|
||||
|
||||
@@ -24,12 +25,10 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
|
||||
return { fromdate: isoDay(yesterday), todate: isoDay(yesterday) };
|
||||
}
|
||||
case 'week': {
|
||||
// Monday-first. A retail week that starts on Sunday makes Monday's
|
||||
// takings the previous week's, which is not how anyone here counts.
|
||||
const weekday = (today.getDay() + 6) % 7;
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - weekday);
|
||||
return { fromdate: isoDay(monday), todate: isoDay(today) };
|
||||
// 7-day rolling window as expected by the user for "one week data"
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(today.getDate() - 6);
|
||||
return { fromdate: isoDay(lastWeek), todate: isoDay(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const first = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
@@ -41,7 +40,6 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
preset: RangePreset;
|
||||
range: DateRange;
|
||||
onChange: (preset: RangePreset, range: DateRange) => void;
|
||||
}
|
||||
@@ -58,13 +56,27 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
return [
|
||||
{ label: 'Today', getRange: () => { const r = presetRange('today'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'Yesterday', getRange: () => { const r = presetRange('yesterday'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This week', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
/* "Last 7 days", not "This week". `presetRange('week')` is a ROLLING
|
||||
window now — today less six — so a label reading "This week" named a
|
||||
calendar week the picker had stopped returning. On a Wednesday the two
|
||||
differ by four days, and the label was the only thing telling the
|
||||
reader which one they had asked for. */
|
||||
{ label: 'Last 7 days', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
{ label: 'This month', getRange: () => { const r = presetRange('month'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
|
||||
];
|
||||
}, []);
|
||||
|
||||
/* No tomorrow.
|
||||
|
||||
The calendar had no upper bound, so every future date was selectable: you
|
||||
could ask this console for next month's takings and it would answer, with
|
||||
an empty report and no indication that the question was the problem rather
|
||||
than the shop. A reporting range cannot end in the future — there is
|
||||
nothing there yet — so the calendar stops at today. */
|
||||
const today = isoDay(new Date()) as ISODateString;
|
||||
|
||||
return (
|
||||
<div style={{ width: 180 }}>
|
||||
<div className="datefilter" style={{ width: 180 }}>
|
||||
<DateRangeInput
|
||||
width={180}
|
||||
size="sm"
|
||||
@@ -72,6 +84,12 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
|
||||
isLabelHidden
|
||||
value={astryxRange}
|
||||
presets={presets}
|
||||
max={today}
|
||||
/* Monday-first. Astryx defaults the grid to Sunday, and a retail week
|
||||
that starts on Sunday puts Monday's takings in the previous week —
|
||||
which is not how anyone here counts. This is the calendar GRID only;
|
||||
it is independent of what the "Last 7 days" preset returns. */
|
||||
weekStartsOn="mon"
|
||||
onChange={(newRange) => {
|
||||
if (!newRange) {
|
||||
onChange('custom', {});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { deliveriesApi } from '@/api/deliveries';
|
||||
import type { DeliveryRow } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { stampNow } from './assignDelivery';
|
||||
import { isSettled, isStalled, matchesStatus } from './orderStatus';
|
||||
|
||||
/**
|
||||
* Moving a delivery along from the back office.
|
||||
@@ -37,8 +38,12 @@ const MOVES = [
|
||||
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
|
||||
] as const;
|
||||
|
||||
/** The end states. Nothing follows them, so nothing is offered. */
|
||||
const SETTLED = ['delivered', 'cancelled'];
|
||||
/** What to do about a stalled job, which is never "nothing". */
|
||||
function stalledAdvice(status: string): string {
|
||||
return matchesStatus('rejected', status)
|
||||
? 'This rider declined the job. The order is waiting for a rider again — assign it to somebody else.'
|
||||
: 'The rider could not hand this over. It is still theirs to re-attempt; reassign it if somebody else should take it.';
|
||||
}
|
||||
|
||||
/**
|
||||
* The moves, split from their rendering.
|
||||
@@ -54,7 +59,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
||||
const isSettled = SETTLED.includes(status);
|
||||
const settled = isSettled(status);
|
||||
const stalled = isStalled(status);
|
||||
|
||||
const move = useMutation({
|
||||
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
||||
@@ -70,7 +76,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
|
||||
|
||||
return {
|
||||
status,
|
||||
isSettled,
|
||||
isSettled: settled,
|
||||
isStalled: stalled,
|
||||
isPending: move.isPending,
|
||||
problem,
|
||||
moves: MOVES,
|
||||
@@ -86,7 +93,8 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const status = (job.orderstatus ?? '').trim().toLowerCase();
|
||||
const isSettled = SETTLED.includes(status);
|
||||
const settled = isSettled(status);
|
||||
const stalled = isStalled(status);
|
||||
|
||||
const move = useMutation({
|
||||
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
|
||||
@@ -100,7 +108,7 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
||||
onError: (error) => setProblem(errorMessage(error)),
|
||||
});
|
||||
|
||||
if (isSettled) {
|
||||
if (settled) {
|
||||
return (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
This job is {status}. Nothing further to record.
|
||||
@@ -108,6 +116,14 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (stalled) {
|
||||
return (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
This job is {status}. {stalledAdvice(status)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<div className="progress-moves">
|
||||
|
||||
108
src/features/store-admin/DeliverySlotsCard.tsx
Normal file
108
src/features/store-admin/DeliverySlotsCard.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { DeliverySlotsEditor } from './DeliverySlotsEditor';
|
||||
|
||||
/**
|
||||
* A branch's delivery windows, on the shop profile.
|
||||
*
|
||||
* Owns its own fetch and save rather than joining the page's one big record:
|
||||
* the windows live in their own table with their own endpoint, and folding them
|
||||
* into the profile's save would mean a failed window edit rolling back a
|
||||
* perfectly good change of phone number.
|
||||
*
|
||||
* ── Editing never moves an order already placed ─────────────────────────────
|
||||
*
|
||||
* The server upserts by (tenant, branch, key) and keeps slot ids stable, so an
|
||||
* order that chose this morning still points at this morning. Changing the
|
||||
* hours changes what FUTURE shoppers are offered, not what past ones agreed to.
|
||||
* Worth knowing before anyone decides to make this a delete-and-recreate.
|
||||
*/
|
||||
export function DeliverySlotsCard({
|
||||
tenantid,
|
||||
locationid,
|
||||
}: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey: ['deliveryslots', tenantid, locationid],
|
||||
queryFn: () => deliverySlotsApi.list(tenantid, locationid),
|
||||
enabled: tenantid > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
/*
|
||||
The working copy.
|
||||
|
||||
Seeded from the server once it answers, and from the defaults when the
|
||||
branch has none — which is every branch until somebody sets them. Local so
|
||||
a half-finished edit is not thrown away by a background refetch.
|
||||
*/
|
||||
const [draft, setDraft] = useState<DeliverySlot[] | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data) return;
|
||||
const fromServer = query.data.details ?? [];
|
||||
setDraft(fromServer.length > 0 ? fromServer : DEFAULT_SLOTS);
|
||||
}, [query.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (slots: DeliverySlot[]) => deliverySlotsApi.save(tenantid, locationid, slots),
|
||||
onMutate: () => {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaved(true);
|
||||
void query.refetch();
|
||||
},
|
||||
// A write that fails silently would leave a shopkeeper believing their
|
||||
// delivery hours had changed when they had not — and the people who find
|
||||
// out are customers.
|
||||
onError: (cause) => setError(errorMessage(cause)),
|
||||
});
|
||||
|
||||
if (query.isLoading || !draft) {
|
||||
return <p className="sp-empty">Loading delivery windows…</p>;
|
||||
}
|
||||
|
||||
const problems = slotProblems(draft);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DeliverySlotsEditor
|
||||
slots={draft}
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
setSaved(false);
|
||||
}}
|
||||
isDisabled={save.isPending}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
|
||||
{/* The design system's Button, matching the Save on every other card on
|
||||
this page. `DrawerButton` is the drawer's own styling and read as a
|
||||
different product standing on a profile card. */}
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : 'Save delivery windows'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={save.isPending}
|
||||
isDisabled={save.isPending || problems.length > 0}
|
||||
onClick={() => save.mutate(draft)}
|
||||
/>
|
||||
{saved ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-ok, #2f855a)' }}>Saved.</span>
|
||||
) : null}
|
||||
{error ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-error, #d64545)' }}>{error}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user