Compare commits
117 Commits
2b8a599b68
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| de7a833678 | |||
| 42cc08a407 | |||
| 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 | |||
| b760c1a078 | |||
| 32c612a10d | |||
| a94c23c20e | |||
| 035ceb44a7 | |||
| 33c4542ddf | |||
| 59828811f9 | |||
| 024fc4adfd | |||
| 12269deeda | |||
| d54fadef20 | |||
| 1a37949bbe | |||
| fdecfe6cc1 | |||
| 56f183f6f6 | |||
| 954dfb8c00 | |||
| c31696ce41 | |||
| 05ac76f5f0 | |||
| 8defbd418f | |||
| d3785cc303 | |||
| 5006a4f0d4 | |||
| 37b0833328 | |||
| 9f1593ff87 | |||
| b8150eb158 | |||
| e1a16377ea | |||
| 416c50755b | |||
| 743aa93e20 | |||
| 25073659d8 | |||
| a8fc4bee69 | |||
| bbe313ba48 | |||
| 5345809951 | |||
| 350eb8f29c | |||
| e1bb0a5307 | |||
| d39956a2f1 | |||
| aad72cfe42 | |||
| d22b79e109 | |||
| 52be0e30ac | |||
| 7df8a49e5f | |||
| b72bbd2f12 | |||
| 98712493c4 | |||
| 599508bc20 | |||
| 9b24d9ec09 | |||
| 22c9f45b44 | |||
| fda463f123 | |||
| a8e0dc069f | |||
| fe78c54eeb | |||
| 5304d9aeb6 | |||
| 55ed13c284 | |||
| b4e77fd6ea | |||
| bd133e7cc6 | |||
| 7e3571f90e | |||
| f4a2962981 | |||
| 29613e25ee | |||
| 32c3bef3ad | |||
| 670d193f36 | |||
| 590e31e6a9 | |||
| dd6a4771ed | |||
| 9f64b42f8a | |||
| bf4dc4e888 | |||
| 0a3e6302b9 | |||
| b7233acd8a | |||
| 7a8c8dc2d7 | |||
| 9a352e9f71 | |||
| 904a5d816f | |||
| eab30640c0 |
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# What must never enter the build context.
|
||||||
|
#
|
||||||
|
# There was no .dockerignore at all, so every one of these was being uploaded to
|
||||||
|
# the Docker daemon on each build and then copied into the builder image by
|
||||||
|
# `COPY . .`.
|
||||||
|
#
|
||||||
|
# `.git` is the one that matters for speed — it carries the entire history, and
|
||||||
|
# it grows forever while being worthless to a build. The rest is correctness:
|
||||||
|
# `dist` and `node_modules` from a developer's machine would be copied in and
|
||||||
|
# then overwritten by the build, but only after being transferred, and a stale
|
||||||
|
# `dist` copied over the fresh one is a genuinely confusing failure.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Transfer archives. The scratch files these rules were written for — the
|
||||||
|
# `_to_delete` directory, `_inv.txt`, and two empty archives — are gone from the
|
||||||
|
# repository; the glob stays so the next one never reaches a build context.
|
||||||
|
*.zip
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and
|
||||||
|
# the build needs it — but `.env.local` is a developer's private override and
|
||||||
|
# must not decide what production talks to.
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
13
.env
Normal file
13
.env
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# The Fiesta API host. Read by Vite at build time and compiled into the bundle.
|
||||||
|
#
|
||||||
|
# This is the single source of truth for where the console talks to the backend,
|
||||||
|
# and it is not a secret — it is the same public host the customer app calls.
|
||||||
|
#
|
||||||
|
# Committed on purpose: a deployed build has to carry it, and a value that lives
|
||||||
|
# only on one developer's machine is one the build server does not have. Vite
|
||||||
|
# only exposes `VITE_`-prefixed names to client code, so nothing else in here
|
||||||
|
# would reach the browser.
|
||||||
|
#
|
||||||
|
# `.env.local` overrides this and is gitignored — that is the file to use for a
|
||||||
|
# staging backend, or `/fiesta` to route through the dev proxy instead.
|
||||||
|
VITE_API_BASE="https://fiesta.nearle.app"
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,3 +4,12 @@ dist
|
|||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
# Transfer archives. Nothing of the sort belongs in the repository — the two
|
||||||
|
# that were here (`_sync.zip`, `_their-src.tgz`) had been committed empty and
|
||||||
|
# stayed for weeks.
|
||||||
|
*.zip
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Build output from scripts/mapPreview.mjs — a local viewer, never committed.
|
||||||
|
scripts/.preview/
|
||||||
|
|||||||
115
Dockerfile
115
Dockerfile
@@ -4,9 +4,83 @@ FROM node:22-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci || npm install
|
|
||||||
|
# `npm ci`, with no `|| npm install` fallback.
|
||||||
|
#
|
||||||
|
# That fallback was here and it is worse than the error it hides. `npm ci`
|
||||||
|
# fails only when package-lock.json disagrees with package.json — exactly the
|
||||||
|
# case where falling back to `npm install` resolves fresh versions the lock file
|
||||||
|
# never pinned, so the deployed bundle is built from dependencies nobody chose
|
||||||
|
# and nobody can reproduce.
|
||||||
|
#
|
||||||
|
# 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 . .
|
COPY . .
|
||||||
|
|
||||||
|
# Where the bundle points at the backend, fixed HERE rather than left to `.env`.
|
||||||
|
#
|
||||||
|
# Deployment platforms write their own `.env` into the source directory before
|
||||||
|
# building — Dokploy does — which overwrites the committed one and takes
|
||||||
|
# VITE_API_BASE with it. The build then fell through to a same-origin path and
|
||||||
|
# the console called `https://<its own domain>/fiesta/live/api/...`. A build
|
||||||
|
# argument outranks the file, so the host survives that overwrite.
|
||||||
|
#
|
||||||
|
# Override per environment with `--build-arg VITE_API_BASE=...` (Dokploy: Build
|
||||||
|
# Args), e.g. to point a staging console at a staging Fiesta.
|
||||||
|
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
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2 — serve
|
# Stage 2 — serve
|
||||||
@@ -28,15 +102,44 @@ COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
|||||||
# and friends. Nginx's own `$uri`, `$remote_addr` and `$proxy_add_x_forwarded_for`
|
# and friends. Nginx's own `$uri`, `$remote_addr` and `$proxy_add_x_forwarded_for`
|
||||||
# would survive that today, but only by luck, and a config silently rewritten at
|
# would survive that today, but only by luck, and a config silently rewritten at
|
||||||
# boot is a bad thing to leave to luck.
|
# boot is a bad thing to leave to luck.
|
||||||
ENV NGINX_ENVSUBST_FILTER=INGEST_TOKEN
|
ENV NGINX_ENVSUBST_FILTER="(INGEST_TOKEN|INGEST_UPSTREAM)"
|
||||||
|
|
||||||
# Empty by default, so the image runs without it. Sheet upload then fails with
|
# Empty by default — and this line is LOAD-BEARING. Do not delete it.
|
||||||
# the ingest service's own 401, which says what is missing — rather than nginx
|
#
|
||||||
# refusing to start and taking the whole console down with it.
|
# BuildKit warns about it: `SecretsUsedInArgOrEnv: Do not use ARG or ENV
|
||||||
|
# instructions for sensitive data (ENV "INGEST_TOKEN")`. The warning is right in
|
||||||
|
# general and wrong here: nothing sensitive is baked in, the value is the empty
|
||||||
|
# string, and the real token is supplied at RUN time by the deployment.
|
||||||
|
#
|
||||||
|
# Removing the line to silence the warning breaks the config in a way that is
|
||||||
|
# hard to see. nginx's entrypoint builds its substitution list from env vars
|
||||||
|
# that are DEFINED:
|
||||||
|
#
|
||||||
|
# defined_envs=$(printf '${%s} ' $(awk "END { for (name in ENVIRON) ... }"))
|
||||||
|
#
|
||||||
|
# With INGEST_TOKEN undefined, it is not in that list, envsubst leaves the
|
||||||
|
# placeholder alone, and nginx ends up with the literal text `${INGEST_TOKEN}`
|
||||||
|
# as the token — which is not empty, so the missing-token guard never fires and
|
||||||
|
# every ingest call goes out with a nonsense `X-API-Key`.
|
||||||
|
#
|
||||||
|
# Declaring it empty here guarantees envsubst always substitutes it, so an
|
||||||
|
# unset token is a real empty string and the guard can catch it.
|
||||||
ENV INGEST_TOKEN=""
|
ENV INGEST_TOKEN=""
|
||||||
|
|
||||||
|
# Where the ingest service is. Declared here for the same reason as the line
|
||||||
|
# above: envsubst only substitutes names that are DEFINED, so an undeclared
|
||||||
|
# INGEST_UPSTREAM would leave the literal text `${INGEST_UPSTREAM}` in the
|
||||||
|
# config as the proxy target, and nginx would fail to start with an error that
|
||||||
|
# names the variable rather than the omission.
|
||||||
|
#
|
||||||
|
# Defaults to the public host so an existing deployment behaves exactly as it
|
||||||
|
# did. Set it to the sibling container's internal address — e.g.
|
||||||
|
# `http://mcp-backend:8000` — to take the private path and drop the credential
|
||||||
|
# entirely.
|
||||||
|
ENV INGEST_UPSTREAM="https://mcp.nearle.ai.in"
|
||||||
|
|
||||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80 3000
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -35,17 +35,36 @@ deployed build set `VITE_API_BASE` (see `.env.example`).
|
|||||||
|
|
||||||
## What's built
|
## 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` — every tenant, branch counts, per-tenant performance
|
||||||
- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue
|
- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue
|
||||||
- `/nearle/onboard/tenant` — provision a merchant group
|
- `/nearle/onboard/tenant` — provision a merchant group and its first outlet
|
||||||
- `/nearle/onboard/branch` — commission an outlet with its delivery thresholds
|
|
||||||
- `/nearle/catalogue` — the global catalogue, plus both product-import paths
|
- `/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
|
**Store Admin** (roleid 1 and 3) — one merchant, every branch:
|
||||||
placeholder rather than a 404, so those roles land somewhere that explains
|
|
||||||
itself.
|
- `/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
|
## Things about the backend that shape this code
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
# Serve the React App
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html index.htm;
|
|
||||||
# Force Nginx to pass routing back to React Router
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy /hasura to the live API, exactly like Vite dev server does
|
|
||||||
location /hasura/ {
|
|
||||||
proxy_pass https://api.workolik.com/api/rest/;
|
|
||||||
proxy_set_header x-hasura-admin-secret "nearle-admin-secret";
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
|
|
||||||
# Pass standard proxy headers
|
|
||||||
proxy_set_header Host api.workolik.com;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
listen 3000;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# 10 MB is the ingest service's own file limit, so anything larger is going
|
# 10 MB is the ingest service's own file limit, so anything larger is going
|
||||||
@@ -36,14 +37,40 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
# React Router owns the paths, so an unknown one is a route, not a 404.
|
# React Router owns the paths, so an unknown one is a route, not a 404.
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# index.html must be revalidated on every visit, and until now it was
|
||||||
|
# not — the line below is new, and its absence was a real outage.
|
||||||
|
#
|
||||||
|
# The block above said "index.html must NOT be cached" and then set no
|
||||||
|
# cache header at all, which is not the same thing. With neither
|
||||||
|
# `Cache-Control` nor `Expires`, a browser falls back to HEURISTIC
|
||||||
|
# caching: RFC 9111 lets it invent a freshness lifetime from
|
||||||
|
# `Last-Modified`, commonly a tenth of the document's age, and serve the
|
||||||
|
# document from disk WITHOUT revalidating. So a tab kept the previous
|
||||||
|
# index.html, that index.html named `InventoryPage-BAzj-ICF.js`, the
|
||||||
|
# deploy had replaced it with `InventoryPage-Cbh53mHU.js`, and the
|
||||||
|
# import 404'd on a screen that had worked ten minutes earlier.
|
||||||
|
#
|
||||||
|
# `no-cache` does NOT mean "do not store" — it means "revalidate before
|
||||||
|
# use". The ETag still answers 304 on an unchanged deploy, so this costs
|
||||||
|
# one conditional request per visit and never a re-download.
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Hashed filenames, so these can be cached hard. index.html must NOT be,
|
# Hashed filenames, so these can be cached hard — the hash changes when the
|
||||||
# or a deploy leaves people on the previous bundle until they force-reload.
|
# content does, which is what makes a year safe.
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
# ONE header, not two. `expires 1y` emits its own
|
||||||
|
# `Cache-Control: max-age=31536000`, and the `add_header` beside it
|
||||||
|
# appended a second, so every asset went out with two conflicting
|
||||||
|
# `Cache-Control` lines — `max-age=31536000` and `public, immutable`,
|
||||||
|
# neither complete. Browsers mostly cope; caches and CDNs in between are
|
||||||
|
# entitled to take the first and drop `immutable`, or to treat the pair
|
||||||
|
# as malformed. Merged into a single directive, with `expires` dropped
|
||||||
|
# because it exists only to emit the header this now sets by hand.
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Fiesta ───────────────────────────────────────────────────────────────
|
# ── Fiesta ───────────────────────────────────────────────────────────────
|
||||||
@@ -78,10 +105,64 @@ server {
|
|||||||
# console's origin and should not need to: the browser only ever talks to
|
# console's origin and should not need to: the browser only ever talks to
|
||||||
# its own host, and this container makes the cross-origin call.
|
# its own host, and this container makes the cross-origin call.
|
||||||
location /ingest/ {
|
location /ingest/ {
|
||||||
proxy_pass https://mcp.nearle.ai.in/;
|
# ── Where the ingest service is, and how we prove who we are ─────────
|
||||||
|
#
|
||||||
|
# Both are environment variables so the deployment can move between two
|
||||||
|
# arrangements without a rebuild:
|
||||||
|
#
|
||||||
|
# INGEST_UPSTREAM=https://mcp.nearle.ai.in INGEST_TOKEN=<secret>
|
||||||
|
# The public host. Needs a credential, because that host is on the
|
||||||
|
# internet and its guards do not care who is asking.
|
||||||
|
#
|
||||||
|
# INGEST_UPSTREAM=http://<container>:<port> INGEST_TOKEN=
|
||||||
|
# The internal Docker network. `app.nearledaily.com` and
|
||||||
|
# `mcp.nearle.ai.in` both resolve to 72.60.218.25 — the same host —
|
||||||
|
# so the two containers can talk without going out to the internet
|
||||||
|
# and back. No secret has to exist on this side at all, which is
|
||||||
|
# the whole point: a credential that is never issued cannot leak,
|
||||||
|
# expire, or be pasted into a chat window.
|
||||||
|
#
|
||||||
|
# The second needs the ingest service to trust its own machine. That is
|
||||||
|
# their change, not ours; this side is ready for either.
|
||||||
|
set $ingest_token "${INGEST_TOKEN}";
|
||||||
|
|
||||||
|
# No 503 guard for a missing token any more, deliberately.
|
||||||
|
#
|
||||||
|
# It existed because an unset token sent no header and the service
|
||||||
|
# answered with the same flat 401 it gives a wrong key. Two problems,
|
||||||
|
# one message. It cannot stay: on the internal network an empty token is
|
||||||
|
# the CORRECT configuration, and a guard that refuses the intended setup
|
||||||
|
# is worse than the ambiguity it was written to remove. The service's own
|
||||||
|
# 401 now names the fix — "Send a bearer token ... or an X-API-Key
|
||||||
|
# header" — which is the sentence the guard was standing in for.
|
||||||
|
#
|
||||||
|
# nginx omits a header whose value is empty, so the line below sends
|
||||||
|
# `X-API-Key` on the public host and nothing at all internally. One
|
||||||
|
# directive, both modes, no branching.
|
||||||
|
|
||||||
|
# `${INGEST_UPSTREAM}` and not `$upstream_variable`, and the difference
|
||||||
|
# is not cosmetic.
|
||||||
|
#
|
||||||
|
# envsubst rewrites this line at container start, so nginx parses a
|
||||||
|
# literal address and behaves exactly as it did when the host was
|
||||||
|
# hardcoded. Putting an nginx VARIABLE in proxy_pass instead changes
|
||||||
|
# three things at once: nginx resolves the name per request rather than
|
||||||
|
# at startup, which requires a `resolver` directive; 127.0.0.11 (Docker's
|
||||||
|
# embedded DNS) exists only on a user-defined network, so on a default
|
||||||
|
# bridge every ingest call fails with "recv() failed ... while resolving";
|
||||||
|
# and a variable proxy_pass stops stripping the location prefix, so the
|
||||||
|
# upstream starts receiving `/ingest/api/...` and 404s. Measured, not
|
||||||
|
# guessed — the first version of this did all three.
|
||||||
|
#
|
||||||
|
# The trailing slash is what strips `/ingest/`, so INGEST_UPSTREAM must
|
||||||
|
# NOT end in one. A name that cannot be resolved now fails at startup
|
||||||
|
# rather than per request, which is the better place to find out.
|
||||||
|
proxy_pass ${INGEST_UPSTREAM}/;
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_set_header Host mcp.nearle.ai.in;
|
# Derived from the upstream rather than hardcoded, so it stays correct
|
||||||
proxy_set_header X-API-Key "${INGEST_TOKEN}";
|
# when the upstream becomes a container name.
|
||||||
|
proxy_set_header Host $proxy_host;
|
||||||
|
proxy_set_header X-API-Key $ingest_token;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
|||||||
1173
package-lock.json
generated
1173
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -8,15 +8,22 @@
|
|||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --port 3100",
|
"preview": "vite preview --port 3100",
|
||||||
"typecheck": "tsc --noEmit",
|
"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",
|
"contract": "node scripts/contract.mjs",
|
||||||
"db": "node scripts/db.mjs",
|
"db": "node scripts/db.mjs",
|
||||||
"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\""
|
"appgap": "node scripts/appgap.mjs",
|
||||||
|
"check:health": "tsx scripts/checkHealthPanel.ts",
|
||||||
|
"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",
|
||||||
|
"preview:map": "node scripts/mapPreview.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astryxdesign/core": "^0.4.5",
|
"@astryxdesign/core": "^0.4.5",
|
||||||
"@stylexjs/stylex": "^0.19.0",
|
"@stylexjs/stylex": "^0.19.0",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.33.0",
|
"lucide-react": "^1.33.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
@@ -27,10 +34,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astryxdesign/cli": "^0.4.5",
|
"@astryxdesign/cli": "^0.4.5",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/jsdom": "^30.0.0",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const req = (label) => `label={<span>${label} <span style={{ color: 'var(--color-error)' }}>*</span></span>}`;
|
|
||||||
|
|
||||||
function processFile(file) {
|
|
||||||
let content = fs.readFileSync(file, 'utf8');
|
|
||||||
|
|
||||||
// Find all components with isRequired
|
|
||||||
// We need to match: label="Something" ... isRequired
|
|
||||||
|
|
||||||
// First, find all lines with label="..."
|
|
||||||
// If a block has isRequired, replace the label and remove isRequired.
|
|
||||||
|
|
||||||
const tags = ['TextInput', 'Selector', 'NumberInput', 'TimeInput'];
|
|
||||||
|
|
||||||
// A simple regex to find JSX tags and their props
|
|
||||||
const regex = /<(TextInput|Selector|NumberInput|TimeInput)([^>]+)isRequired([^>]*)>/g;
|
|
||||||
|
|
||||||
content = content.replace(regex, (match, tag, before, after) => {
|
|
||||||
// extract label
|
|
||||||
const labelMatch = before.match(/label="([^"]+)"/);
|
|
||||||
if (!labelMatch) {
|
|
||||||
// maybe label is after isRequired
|
|
||||||
const labelMatchAfter = after.match(/label="([^"]+)"/);
|
|
||||||
if (!labelMatchAfter) return match; // fallback
|
|
||||||
|
|
||||||
const newLabel = req(labelMatchAfter[1]);
|
|
||||||
return `<${tag}${before}${after.replace(labelMatchAfter[0], newLabel)}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLabel = req(labelMatch[1]);
|
|
||||||
return `<${tag}${before.replace(labelMatch[0], newLabel)}${after}>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(file, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
processFile('src/features/nearle-admin/pages/OnboardTenantPage.tsx');
|
|
||||||
processFile('src/features/nearle-admin/pages/OnboardBranchPage.tsx');
|
|
||||||
|
|
||||||
console.log("Done");
|
|
||||||
262
scripts/appfix.mjs
Normal file
262
scripts/appfix.mjs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* Repairs the products the customer app cannot show.
|
||||||
|
*
|
||||||
|
* node scripts/appfix.mjs # dry run — prints, writes nothing
|
||||||
|
* node scripts/appfix.mjs --apply # performs the repair
|
||||||
|
* node scripts/appfix.mjs --apply --tenant 1135
|
||||||
|
*
|
||||||
|
* `appsweep.mjs` finds them; this puts them right. Same detection, so the two
|
||||||
|
* cannot disagree about what is broken.
|
||||||
|
*
|
||||||
|
* ── What it does, and why it is shaped like this ─────────────────────────────
|
||||||
|
*
|
||||||
|
* The repair is "give the product a category", and for a long time there was no
|
||||||
|
* way to do it. `products/update` writes only `productlocations.status` despite
|
||||||
|
* its name, and `importcatalogueproduct` took an existing product down a branch
|
||||||
|
* that corrected the PRICE and left the category alone — so re-importing, the
|
||||||
|
* obvious fix, appeared to work and changed nothing.
|
||||||
|
*
|
||||||
|
* That branch now also calls `UpdateProductCategory`
|
||||||
|
* (`services/productService.go`), which makes re-import the repair path. This
|
||||||
|
* script drives it: for each orphan it re-sends the original import with a real
|
||||||
|
* `categoryid`.
|
||||||
|
*
|
||||||
|
* REQUIRES THE FIXED BACKEND. Against the currently deployed one every call
|
||||||
|
* returns 200 and nothing changes, which is exactly the failure that makes this
|
||||||
|
* bug expensive — so the script verifies each product afterwards and reports
|
||||||
|
* what actually moved rather than what it asked for.
|
||||||
|
*
|
||||||
|
* ── What it deliberately does not do ─────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is
|
||||||
|
* written — `CreateProductLocation` only records stock when quantity > 0. The
|
||||||
|
* products already have their stock and this must not add to it.
|
||||||
|
*
|
||||||
|
* It re-sends each product's EXISTING price, cost and tax, because the same
|
||||||
|
* call updates pricing. Sending zeros would wipe the prices while fixing the
|
||||||
|
* category.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
|
||||||
|
const WEB = `${BASE}/live/api/v1/web`;
|
||||||
|
|
||||||
|
const apply = process.argv.includes('--apply');
|
||||||
|
const tenantArg = process.argv.indexOf('--tenant');
|
||||||
|
const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null;
|
||||||
|
|
||||||
|
async function get(path, params = {}) {
|
||||||
|
const query = new URLSearchParams(
|
||||||
|
Object.entries(params).filter(([, v]) => v !== undefined && v !== ''),
|
||||||
|
);
|
||||||
|
const response = await fetch(`${WEB}${path}?${query}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`);
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return payload?.details ?? payload?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
const response = await fetch(`${WEB}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return { ok: response.ok && payload?.status !== false, status: response.status, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tenantsWithOrphans() {
|
||||||
|
const seen = new Map();
|
||||||
|
for (let page = 1; page <= 20; page++) {
|
||||||
|
const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? [];
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
for (const t of list) if (t?.tenantid) seen.set(t.tenantid, t);
|
||||||
|
if (list.length < 200) break;
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const tenant of seen.values()) {
|
||||||
|
if (onlyTenant && tenant.tenantid !== onlyTenant) continue;
|
||||||
|
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||||
|
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
|
||||||
|
const orphans = products.filter((p) => !p.categoryid);
|
||||||
|
if (orphans.length === 0) continue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each orphan already sits.
|
||||||
|
*
|
||||||
|
* `getallproducts` is tenant-wide and carries NO locationid — the first
|
||||||
|
* version of this read `p.locationid` off it, got undefined, sent 0, and
|
||||||
|
* every repair came back "missing required field(s): locationid". Nothing
|
||||||
|
* was written, which is the one good thing about that failure.
|
||||||
|
*
|
||||||
|
* The outlet matters beyond passing validation. Import writes a
|
||||||
|
* productlocations row, so naming an outlet the product is NOT on would put
|
||||||
|
* it on that shelf — silently extending the product's reach as a side
|
||||||
|
* effect of a repair. Only an outlet where it is already stocked is safe:
|
||||||
|
* there the upsert lands on the existing row.
|
||||||
|
*/
|
||||||
|
const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? [];
|
||||||
|
const placement = new Map();
|
||||||
|
for (const loc of Array.isArray(locations) ? locations : []) {
|
||||||
|
const rows = (await get('/products/getlocationproducts', {
|
||||||
|
tenantid: tenant.tenantid,
|
||||||
|
locationid: loc.locationid,
|
||||||
|
pageno: 1,
|
||||||
|
pagesize: 500,
|
||||||
|
})) ?? [];
|
||||||
|
for (const row of Array.isArray(rows) ? rows : []) {
|
||||||
|
if (!placement.has(row.productid)) placement.set(row.productid, { loc, row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({ tenant, orphans, placement });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The category to file a product under.
|
||||||
|
*
|
||||||
|
* `gettenantcategories` is synthesised from the categories the tenant's own
|
||||||
|
* products already use, so it is the tenant's real answer rather than the
|
||||||
|
* master table's — which is unscoped and, for the tenants seen here, offered a
|
||||||
|
* category (1001) that the app does not browse.
|
||||||
|
*
|
||||||
|
* Refusing rather than guessing when the tenant has none: a wrong category is
|
||||||
|
* findable and fixable, but writing one at random across a live catalogue is
|
||||||
|
* not something a repair script should decide.
|
||||||
|
*/
|
||||||
|
async function categoryFor(tenantid) {
|
||||||
|
const rows = (await get('/products/gettenantcategories', { tenantid })) ?? [];
|
||||||
|
const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0);
|
||||||
|
return usable[0]?.categoryid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = await tenantsWithOrphans();
|
||||||
|
|
||||||
|
if (work.length === 0) {
|
||||||
|
console.log('Nothing to repair — no product is missing a category.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n');
|
||||||
|
|
||||||
|
let repaired = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
let refused = 0;
|
||||||
|
|
||||||
|
for (const { tenant, orphans, placement } of work) {
|
||||||
|
const categoryid = await categoryFor(tenant.tenantid);
|
||||||
|
console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`);
|
||||||
|
|
||||||
|
if (!categoryid) {
|
||||||
|
console.log(' SKIPPED: this tenant has no category of its own to file into.\n');
|
||||||
|
refused += orphans.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of orphans) {
|
||||||
|
const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`;
|
||||||
|
|
||||||
|
if (!p.productbrand || !p.catalogueid) {
|
||||||
|
// Not imported from the global catalogue, so brand+catalogueid cannot
|
||||||
|
// address it and re-import is not available. Says so rather than
|
||||||
|
// reporting a success it did not achieve.
|
||||||
|
console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`);
|
||||||
|
refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = placement.get(p.productid);
|
||||||
|
if (!at) {
|
||||||
|
console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`);
|
||||||
|
refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A product with no price must not be made visible.
|
||||||
|
*
|
||||||
|
* Repairing the category is what puts a product in front of shoppers, and
|
||||||
|
* the app has no price floor — `GetProducts` filters on category and outlet
|
||||||
|
* and nothing else. Fixing a product priced at 0 would not "restore" it; it
|
||||||
|
* would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly
|
||||||
|
* this state, priced nowhere, and it wants a price before it wants a
|
||||||
|
* category.
|
||||||
|
*/
|
||||||
|
const price = Number(p.retailprice ?? 0);
|
||||||
|
if (!(price > 0)) {
|
||||||
|
console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`);
|
||||||
|
refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
console.log(
|
||||||
|
` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await post('/products/importcatalogueproduct', [
|
||||||
|
{
|
||||||
|
tenantid: tenant.tenantid,
|
||||||
|
// An outlet the product ALREADY sits at, so the upsert lands on the
|
||||||
|
// existing row instead of putting it on a new shelf. The category
|
||||||
|
// itself is written to `products`, which is tenant-wide, so one call
|
||||||
|
// fixes the product everywhere.
|
||||||
|
locationid: at.loc.locationid,
|
||||||
|
brand: p.productbrand,
|
||||||
|
catalogueid: p.catalogueid,
|
||||||
|
categoryid,
|
||||||
|
subcategoryid: p.subcategoryid ?? 0,
|
||||||
|
// Zero, so CreateProductLocation writes no stock ledger entry — it only
|
||||||
|
// records stock when quantity > 0. These products already have their
|
||||||
|
// stock and a repair must not add to it.
|
||||||
|
quantity: 0,
|
||||||
|
stocktype: 'in',
|
||||||
|
status: at.row.productstatus || p.productstatus || 'Active',
|
||||||
|
// The product's OWN current values, re-sent unchanged. The same call
|
||||||
|
// updates pricing, and the location upsert sets productlocations.price
|
||||||
|
// from retailprice — verified equal for every product being repaired
|
||||||
|
// here, so this round-trips rather than overwriting an outlet price.
|
||||||
|
retailprice: price,
|
||||||
|
productcost: p.productcost ?? 0,
|
||||||
|
taxpercent: p.taxpercent ?? 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`);
|
||||||
|
refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified, not assumed. The whole reason this bug survived is that the
|
||||||
|
// call that was supposed to fix it returned success and did nothing.
|
||||||
|
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||||
|
const after = (Array.isArray(groups) ? groups : [])
|
||||||
|
.flatMap((g) => g?.products ?? [])
|
||||||
|
.find((x) => x.productid === p.productid);
|
||||||
|
|
||||||
|
if (after?.categoryid > 0) {
|
||||||
|
console.log(` ${label} → categoryid ${after.categoryid} ✓`);
|
||||||
|
repaired++;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
` ${label} — NO CHANGE: the call succeeded but the category is still 0.` +
|
||||||
|
' The backend fix is not deployed.',
|
||||||
|
);
|
||||||
|
unchanged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`);
|
||||||
|
if (unchanged > 0) {
|
||||||
|
console.log(
|
||||||
|
'\nProducts reported NO CHANGE need the backend fix deployed' +
|
||||||
|
' (services/productService.go — re-import must call UpdateProductCategory).',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
193
scripts/appgap.mjs
Normal file
193
scripts/appgap.mjs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Why the customer app shows fewer products than the console does.
|
||||||
|
*
|
||||||
|
* node scripts/appgap.mjs <tenantid> <locationid>
|
||||||
|
* npm run appgap 1135 1166
|
||||||
|
*
|
||||||
|
* Straight at Fiesta — no Hasura, no admin secret, nothing to configure. The
|
||||||
|
* first version of this went at the database through Hasura, which was the
|
||||||
|
* wrong instrument twice over: it needed a secret, it 404'd on an admin API
|
||||||
|
* that is often disabled, and it answered a question about the DATABASE when
|
||||||
|
* the question is about what the API returns. This calls the very endpoint the
|
||||||
|
* app calls and compares it with what the console can see.
|
||||||
|
*
|
||||||
|
* ── The three filters ────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `getproductsbysubcategory` drops a product for one of three reasons, and none
|
||||||
|
* of them is an error, logged or visible from either end:
|
||||||
|
*
|
||||||
|
* A. `WHERE a.categoryid = 2` — not optional (`productRepository.go:865`).
|
||||||
|
* A product in another category cannot appear, whatever else is true.
|
||||||
|
*
|
||||||
|
* B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. No row
|
||||||
|
* for this outlet means the join yields NULL and the WHERE drops it. Being
|
||||||
|
* in the catalogue is not the same as being on a shelf.
|
||||||
|
*
|
||||||
|
* C. The grouping in `GetProductsBySubcategory` collects products under each
|
||||||
|
* real subcategory of category 2, then sweeps up `subcategoryid = 0` as
|
||||||
|
* "Uncategorized". A subcategoryid that is non-zero and NOT a subcategory
|
||||||
|
* of 2 matches neither and vanishes — present in the SQL, absent from the
|
||||||
|
* JSON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Through the console's own host, not Fiesta directly.
|
||||||
|
*
|
||||||
|
* `app.nearledaily.com/fiesta/...` is the nginx proxy the deployed console
|
||||||
|
* already uses, so this script exercises exactly the path the browser takes —
|
||||||
|
* if the proxy is misconfigured this finds out, where hitting fiesta.nearle.app
|
||||||
|
* would quietly work and prove nothing about production.
|
||||||
|
*
|
||||||
|
* Override with FIESTA_URL to point at the backend directly or at a dev server.
|
||||||
|
*/
|
||||||
|
const BASE = process.env.FIESTA_URL ?? 'https://app.nearledaily.com/fiesta';
|
||||||
|
const WEB = `${BASE}/live/api/v1/web`;
|
||||||
|
const MOB = `${BASE}/live/api/v1/mob`;
|
||||||
|
|
||||||
|
const [, , tenantArg, locationArg] = process.argv;
|
||||||
|
const tenantid = Number(tenantArg);
|
||||||
|
const locationid = Number(locationArg);
|
||||||
|
|
||||||
|
if (!tenantid || !locationid) {
|
||||||
|
console.error('Usage: node scripts/appgap.mjs <tenantid> <locationid>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fiesta answers under `details` in most places and `data` in a few. */
|
||||||
|
async function get(url, params) {
|
||||||
|
const query = new URLSearchParams(
|
||||||
|
Object.entries(params).filter(([, value]) => value !== undefined && value !== ''),
|
||||||
|
);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${url}?${query}`, { headers: { Accept: 'application/json' } });
|
||||||
|
} catch (cause) {
|
||||||
|
console.error(`Could not reach ${BASE} — ${cause.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!payload) {
|
||||||
|
console.error(`Malformed response from ${url} (HTTP ${response.status})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return payload.details ?? payload.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every product the tenant owns.
|
||||||
|
*
|
||||||
|
* `getallproducts` answers `[]models.Tenantproducts` — `{tenant, products}`
|
||||||
|
* groups, not a flat list — and under `data` rather than `details`.
|
||||||
|
*/
|
||||||
|
async function allProducts() {
|
||||||
|
const groups = await get(`${WEB}/products/getallproducts`, { tenantid });
|
||||||
|
if (!Array.isArray(groups)) return [];
|
||||||
|
return groups.flatMap((group) => group?.products ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is actually listed at this outlet.
|
||||||
|
*
|
||||||
|
* Paged, and the page size matters: the default is 50, so a shop with 200
|
||||||
|
* products would look like one with 50 and every product past the first page
|
||||||
|
* would be miscounted as "not listed". Walked until a short page comes back.
|
||||||
|
*/
|
||||||
|
async function locationProducts() {
|
||||||
|
const rows = [];
|
||||||
|
const pagesize = 200;
|
||||||
|
for (let pageno = 1; pageno <= 50; pageno += 1) {
|
||||||
|
const page = await get(`${WEB}/products/getlocationproducts`, {
|
||||||
|
tenantid,
|
||||||
|
locationid,
|
||||||
|
pageno,
|
||||||
|
pagesize,
|
||||||
|
});
|
||||||
|
const batch = Array.isArray(page) ? page : [];
|
||||||
|
rows.push(...batch);
|
||||||
|
if (batch.length < pagesize) break;
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exactly what the app asks for, so the comparison is against reality. */
|
||||||
|
async function appView() {
|
||||||
|
const payload = await get(`${MOB}/products/getproductsbysubcategory`, {
|
||||||
|
categoryid: 2,
|
||||||
|
tenantid,
|
||||||
|
locationid,
|
||||||
|
});
|
||||||
|
const details = payload?.details ?? (Array.isArray(payload) ? payload : []);
|
||||||
|
return Array.isArray(details) ? details : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [products, listedRows, groups, subcategories] = await Promise.all([
|
||||||
|
allProducts(),
|
||||||
|
locationProducts(),
|
||||||
|
appView(),
|
||||||
|
get(`${WEB}/products/getproductsubcategories`, { tenantid, categoryid: 2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
console.log(`Tenant ${tenantid} has no products at all — nothing for the app to show.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = new Set(listedRows.map((row) => row.productid));
|
||||||
|
/**
|
||||||
|
* `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`.
|
||||||
|
*
|
||||||
|
* This read the long names — the ones `getproductsubcategories` does NOT send —
|
||||||
|
* so every entry was `undefined → undefined`, the map collapsed to a single
|
||||||
|
* junk key, and check C below could never match. The footer duly announced
|
||||||
|
* "(none returned)" for tenant 1147, whose category 2 has six subcategories.
|
||||||
|
* A diagnostic that is confidently wrong is worse than one that is missing.
|
||||||
|
*/
|
||||||
|
const realSubs = new Map(
|
||||||
|
(Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inApp = new Set();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const product of group.products ?? []) inApp.add(product.productid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
const examples = new Map();
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
let reason;
|
||||||
|
if (inApp.has(product.productid)) {
|
||||||
|
reason = 'OK — the app shows this';
|
||||||
|
} else if (product.categoryid !== 2) {
|
||||||
|
reason = `A — categoryid is ${product.categoryid}, the app only asks for 2`;
|
||||||
|
} else if (!listed.has(product.productid)) {
|
||||||
|
reason = 'B — not listed at this outlet (no productlocations row)';
|
||||||
|
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
|
||||||
|
reason = `C — subcategoryid ${product.subcategoryid} is not a subcategory of 2, so it is dropped`;
|
||||||
|
} else {
|
||||||
|
// Everything checks out and it still is not there. Worth its own bucket
|
||||||
|
// rather than being folded into one of the above: a wrong guess here would
|
||||||
|
// send someone fixing data that is already correct.
|
||||||
|
reason = '? — passes all three checks but the app still does not return it';
|
||||||
|
}
|
||||||
|
const key = reason.replace(/\d+/g, 'N');
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
if (!examples.has(key)) examples.set(key, { product, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Tenant ${tenantid}, outlet ${locationid}`);
|
||||||
|
console.log(` ${products.length} products in the catalogue`);
|
||||||
|
console.log(` ${listed.size} listed at this outlet`);
|
||||||
|
console.log(` ${inApp.size} returned by the app's endpoint\n`);
|
||||||
|
|
||||||
|
for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1])) {
|
||||||
|
const { product, reason } = examples.get(key);
|
||||||
|
console.log(` ${String(count).padStart(5)} ${reason}`);
|
||||||
|
console.log(
|
||||||
|
` e.g. "${product.productname}" — id ${product.productid}, category ${product.categoryid}, subcategory ${product.subcategoryid}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none returned)'}`,
|
||||||
|
);
|
||||||
246
scripts/appsweep.mjs
Normal file
246
scripts/appsweep.mjs
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Every product on the platform the customer app cannot show, and why.
|
||||||
|
*
|
||||||
|
* node scripts/appsweep.mjs
|
||||||
|
* node scripts/appsweep.mjs --json > sweep.json
|
||||||
|
*
|
||||||
|
* `appgap.mjs` answers this for ONE outlet and is the tool to reach for when
|
||||||
|
* somebody reports a specific shop. This is the platform-wide version: it walks
|
||||||
|
* every tenant, in every approval state, and reports the products that are
|
||||||
|
* unreachable no matter what the app asks for.
|
||||||
|
*
|
||||||
|
* ── The rule it applies ──────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Read off the backend rather than inferred from responses
|
||||||
|
* (`controllers/productController.go:431`, `repositories/productRepository.go:858`):
|
||||||
|
*
|
||||||
|
* 1. `GetProductsBySubcategory` REJECTS `categoryid = 0` with a 400 —
|
||||||
|
* "Valid categoryid is required". It is the first thing the controller
|
||||||
|
* does.
|
||||||
|
* 2. The query then filters `WHERE a.categoryid = ?` unconditionally.
|
||||||
|
*
|
||||||
|
* Together those mean a product stored with `categoryid = 0` is returned for NO
|
||||||
|
* request the app can make. Not "usually hidden" — unreachable. It is still
|
||||||
|
* listed by `getlocationproducts`, which does not filter on category, so the
|
||||||
|
* console shows it and the shop believes it is on sale.
|
||||||
|
*
|
||||||
|
* Nothing else in that query gates visibility: there is no filter on
|
||||||
|
* `approved`, `publishedat`, `productstatus`, or stock level. A product with
|
||||||
|
* zero stock still appears. So `categoryid = 0` is the whole defect, and this
|
||||||
|
* script looks for exactly it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
|
||||||
|
const WEB = `${BASE}/live/api/v1/web`;
|
||||||
|
|
||||||
|
const asJson = process.argv.includes('--json');
|
||||||
|
|
||||||
|
/** Fiesta answers under `details` in most places and `data` in a few. */
|
||||||
|
async function get(path, params = {}) {
|
||||||
|
const query = new URLSearchParams(
|
||||||
|
Object.entries(params).filter(([, v]) => v !== undefined && v !== ''),
|
||||||
|
);
|
||||||
|
const response = await fetch(`${WEB}${path}?${query}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`);
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return payload?.details ?? payload?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests that never succeeded. A non-empty list invalidates the report. */
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded concurrency, with retries, and failures that are never swallowed.
|
||||||
|
*
|
||||||
|
* The first version of this caught every error and substituted `null`. Run
|
||||||
|
* across 262 tenants at a concurrency of 8, enough requests were refused that
|
||||||
|
* it reported ONE affected tenant out of three known ones — and reported it as
|
||||||
|
* a clean result, with no indication anything had gone wrong. A sweep that
|
||||||
|
* fails silently is worse than no sweep: it is used to close the investigation.
|
||||||
|
*
|
||||||
|
* So: three attempts with a widening delay, and anything still failing is
|
||||||
|
* recorded and printed at the end as an explicit gap in coverage.
|
||||||
|
*/
|
||||||
|
async function mapLimit(items, limit, fn, label = 'request') {
|
||||||
|
const out = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
out[i] = await fn(items[i], i);
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
} catch (cause) {
|
||||||
|
lastError = cause;
|
||||||
|
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) {
|
||||||
|
out[i] = null;
|
||||||
|
failures.push(`${label}[${i}]: ${lastError?.message ?? lastError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tenant, from two sources because neither is complete on its own.
|
||||||
|
*
|
||||||
|
* `getalltenants` paginates and its `pageno` is 1-BASED — page 0 returns an
|
||||||
|
* empty list rather than the first page, the same off-by-one that
|
||||||
|
* `getlocationproducts` has. It carries 262 tenants where the status lists
|
||||||
|
* carry 142.
|
||||||
|
*
|
||||||
|
* `/tenants/search` is still needed alongside it: it branches on the word
|
||||||
|
* "pending" and queries `approved = 0` instead of a status
|
||||||
|
* (`tenantRepository.go:45-77`), which is the only way to learn that a tenant
|
||||||
|
* is unapproved. Suriya Store is one.
|
||||||
|
*
|
||||||
|
* Building from the status lists ALONE was the first version's other bug: R
|
||||||
|
* mart (1147) appears in none of the three, and a sweep that cannot see a
|
||||||
|
* tenant reports it as having nothing wrong.
|
||||||
|
*/
|
||||||
|
async function allTenants() {
|
||||||
|
const seen = new Map();
|
||||||
|
|
||||||
|
for (let page = 1; page <= 20; page++) {
|
||||||
|
const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? [];
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
for (const t of list) if (t?.tenantid && !seen.has(t.tenantid)) seen.set(t.tenantid, { ...t });
|
||||||
|
if (list.length < 200) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of ['Active', 'pending', 'InActive']) {
|
||||||
|
const rows = (await get('/tenants/search', { status })) ?? [];
|
||||||
|
for (const t of Array.isArray(rows) ? rows : []) {
|
||||||
|
if (!t?.tenantid) continue;
|
||||||
|
const existing = seen.get(t.tenantid) ?? { ...t };
|
||||||
|
seen.set(t.tenantid, { ...existing, approvalState: status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = await allTenants();
|
||||||
|
if (!asJson) console.error(`Scanning ${tenants.length} tenants…`);
|
||||||
|
|
||||||
|
/* Pass one — the whole platform, one request per tenant.
|
||||||
|
`getallproducts` returns {tenant, products} groups, not a flat list. */
|
||||||
|
const scanned = await mapLimit(tenants, 8, async (tenant) => {
|
||||||
|
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||||
|
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
|
||||||
|
return { tenant, products, orphans: products.filter((p) => !p.categoryid) };
|
||||||
|
}, "tenant-products");
|
||||||
|
|
||||||
|
const affected = scanned.filter((row) => row && row.orphans.length > 0);
|
||||||
|
|
||||||
|
/* Pass two — only the tenants that failed, so the outlet detail costs nothing
|
||||||
|
on a clean platform. */
|
||||||
|
const detailed = await mapLimit(affected, 6, async (row) => {
|
||||||
|
const locations = (await get('/tenants/gettenantlocations', { tenantid: row.tenant.tenantid })) ?? [];
|
||||||
|
const branches = await mapLimit(Array.isArray(locations) ? locations : [], 4, async (loc) => {
|
||||||
|
const shelved = (await get('/products/getlocationproducts', {
|
||||||
|
tenantid: row.tenant.tenantid,
|
||||||
|
locationid: loc.locationid,
|
||||||
|
pageno: 1,
|
||||||
|
pagesize: 500,
|
||||||
|
})) ?? [];
|
||||||
|
const list = Array.isArray(shelved) ? shelved : [];
|
||||||
|
const hidden = list.filter((p) => !p.categoryid);
|
||||||
|
return {
|
||||||
|
locationid: loc.locationid,
|
||||||
|
locationname: loc.locationname,
|
||||||
|
shelved: list.length,
|
||||||
|
hidden: hidden.length,
|
||||||
|
// The number that matters to a shopper: an outlet whose entire range is
|
||||||
|
// invisible looks like a closed shop, not like a partial catalogue.
|
||||||
|
visible: list.length - hidden.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { ...row, branches: branches.filter(Boolean) };
|
||||||
|
}, "tenant-branches");
|
||||||
|
|
||||||
|
/* Coverage is part of the result, not a footnote. A tenant whose products
|
||||||
|
never loaded is UNKNOWN, not clean, and the difference decides whether this
|
||||||
|
report can be used to say the platform is fixed. */
|
||||||
|
const unreached = scanned.filter((row) => !row).length;
|
||||||
|
|
||||||
|
if (asJson) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
scannedTenants: tenants.length,
|
||||||
|
affectedTenants: detailed.length,
|
||||||
|
orphanProducts: detailed.reduce((n, r) => n + r.orphans.length, 0),
|
||||||
|
tenants: detailed.map((r) => ({
|
||||||
|
tenantid: r.tenant.tenantid,
|
||||||
|
tenantname: r.tenant.tenantname,
|
||||||
|
approvalState: r.tenant.approvalState,
|
||||||
|
totalProducts: r.products.length,
|
||||||
|
orphans: r.orphans.map((p) => ({
|
||||||
|
productid: p.productid,
|
||||||
|
productname: p.productname,
|
||||||
|
productbrand: p.productbrand,
|
||||||
|
catalogueid: p.catalogueid,
|
||||||
|
retailprice: p.retailprice,
|
||||||
|
})),
|
||||||
|
branches: r.branches,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const orphanCount = detailed.reduce((n, r) => n + r.orphans.length, 0);
|
||||||
|
const blindOutlets = detailed.flatMap((r) =>
|
||||||
|
r.branches.filter((b) => b.shelved > 0 && b.visible === 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n${tenants.length} tenants scanned`);
|
||||||
|
console.log(`${detailed.length} affected`);
|
||||||
|
console.log(`${orphanCount} products with categoryid 0 — invisible in the app`);
|
||||||
|
console.log(`${blindOutlets.length} outlets stocked but showing NOTHING to shoppers\n`);
|
||||||
|
|
||||||
|
if (unreached > 0 || failures.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`!! ${unreached} tenants could not be read after 3 attempts — this report is INCOMPLETE
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
for (const f of failures.slice(0, 10)) console.log(` ${f}`);
|
||||||
|
if (failures.length > 10) console.log(` … ${failures.length - 10} more
|
||||||
|
`);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of detailed.sort((a, b) => b.orphans.length - a.orphans.length)) {
|
||||||
|
const { tenant } = row;
|
||||||
|
console.log(
|
||||||
|
`${tenant.tenantid} ${tenant.tenantname}` +
|
||||||
|
` — ${row.orphans.length}/${row.products.length} products hidden` +
|
||||||
|
(tenant.approvalState === 'pending' ? ' [unapproved]' : ''),
|
||||||
|
);
|
||||||
|
for (const p of row.orphans) {
|
||||||
|
console.log(` ${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`);
|
||||||
|
}
|
||||||
|
for (const b of row.branches) {
|
||||||
|
if (b.shelved === 0) continue;
|
||||||
|
const flag = b.visible === 0 ? ' ← app shows an EMPTY shop' : '';
|
||||||
|
console.log(
|
||||||
|
` · ${String(b.locationname ?? b.locationid).padEnd(28)}` +
|
||||||
|
` ${b.visible}/${b.shelved} visible${flag}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
91
scripts/checkHealthPanel.ts
Normal file
91
scripts/checkHealthPanel.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* What the health panel would render, for real products, against the live
|
||||||
|
* service.
|
||||||
|
*
|
||||||
|
* Not a test — the tests pin behaviour against a frozen fixture. This runs the
|
||||||
|
* SAME functions the panel calls against whatever the service is returning
|
||||||
|
* right now, which is the only way to catch the service changing under us.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/checkHealthPanel.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nutritionApi, __resolveBrand } from '../src/api/nutrition';
|
||||||
|
import { facts, present, BAND_LABEL } from '../src/features/store-admin/healthScore';
|
||||||
|
|
||||||
|
const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [
|
||||||
|
{
|
||||||
|
label: 'Cadbury 5 Star 200g',
|
||||||
|
brand: 'Cadbury',
|
||||||
|
imageId: 'cadbury_cadbury_5_star_200g',
|
||||||
|
expect: 'a score, with a low-confidence caveat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Godrej Hit Spray (INSECTICIDE)',
|
||||||
|
brand: 'Godrej',
|
||||||
|
imageId: 'godrej_hit_spray_1101d017',
|
||||||
|
expect: 'NO score — blocked by the edibility guard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Naga Sooji',
|
||||||
|
brand: 'Naga',
|
||||||
|
imageId: 'naga_naga_sooji_100g',
|
||||||
|
expect: 'a high score, allergen declared',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Aachi Baby Fryums 100g (a real merchant product)',
|
||||||
|
brand: 'Aachi',
|
||||||
|
imageId: 'aachi_aachi_baby_fryums_100g',
|
||||||
|
expect: 'known but unscored',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const line = (s = '') => console.log(s);
|
||||||
|
|
||||||
|
for (const testCase of CASES) {
|
||||||
|
line();
|
||||||
|
line('─'.repeat(72));
|
||||||
|
line(`${testCase.label}`);
|
||||||
|
line(`expected: ${testCase.expect}`);
|
||||||
|
line('─'.repeat(72));
|
||||||
|
|
||||||
|
const raw = await nutritionApi.forProduct(testCase.brand, testCase.imageId);
|
||||||
|
const shown = present(raw);
|
||||||
|
|
||||||
|
if (shown.isEmpty) {
|
||||||
|
line(' → "No health score available for this product yet."');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (shown.isPending) {
|
||||||
|
line(' → "This product is in the catalogue but has not been scored yet."');
|
||||||
|
line(` (service sent health_score=${raw?.health_score}, category="${raw?.category}")`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
line(` SCORE ${shown.display}/100 ${shown.band ? BAND_LABEL[shown.band] : ''}`);
|
||||||
|
for (const good of shown.good) line(` ✓ ${good}`);
|
||||||
|
for (const caution of shown.cautions) line(` ! ${caution}`);
|
||||||
|
if (shown.tags.length) line(` TAGS ${shown.tags.join(' · ')}`);
|
||||||
|
if (shown.allergens.length) line(` ALLERGENS Contains ${shown.allergens.join(', ')}`);
|
||||||
|
else if (shown.allergensUnconfirmed) line(' ALLERGENS not confirmed — check the pack');
|
||||||
|
const rows = facts(raw);
|
||||||
|
if (rows.length) line(` PER 100g ${rows.map((r) => `${r.label} ${r.value}`).join(' · ')}`);
|
||||||
|
if (shown.caveat) line(` CAVEAT ${shown.caveat}`);
|
||||||
|
if (shown.source) line(` SOURCE ${shown.source.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
line();
|
||||||
|
|
||||||
|
/* ── Brand vocabularies ──────────────────────────────────────────────────── */
|
||||||
|
/* Our catalogue writes snake_case, theirs writes Title Case with a separator
|
||||||
|
that is sometimes a space and sometimes a hyphen. A mismatch returns
|
||||||
|
health_score: null — indistinguishable from an unscored product — so this
|
||||||
|
checks the resolution rather than trusting it. */
|
||||||
|
|
||||||
|
line('─'.repeat(72));
|
||||||
|
line('Brand resolution: our spelling → theirs');
|
||||||
|
line('─'.repeat(72));
|
||||||
|
for (const ours of ['cadbury', 'coca_cola', 'brooke_bond', '24_mantra', 'colgate_palmolive', 'aachi']) {
|
||||||
|
const theirs = await __resolveBrand(ours);
|
||||||
|
line(` ${ours.padEnd(20)} → ${theirs}`);
|
||||||
|
}
|
||||||
|
line();
|
||||||
89
scripts/checkOptimiser.ts
Normal file
89
scripts/checkOptimiser.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* The route-plan chain, run against the live optimiser.
|
||||||
|
*
|
||||||
|
* Not a test — the unit tests pin `routePlan.ts` against a frozen fixture. This
|
||||||
|
* calls the real service with real order rows and pushes the answer through the
|
||||||
|
* same functions the drawer uses, which is the only way to notice the service
|
||||||
|
* changing shape under us.
|
||||||
|
*
|
||||||
|
* npm run check:optimiser
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { optimiserApi } from '../src/api/optimiser';
|
||||||
|
import type { OrderRow, RiderInfo } from '../src/api/types';
|
||||||
|
import {
|
||||||
|
applyReconcile,
|
||||||
|
commitProblem,
|
||||||
|
dirtyRiders,
|
||||||
|
planFromSequence,
|
||||||
|
splitRoutable,
|
||||||
|
planKms,
|
||||||
|
reorderStops,
|
||||||
|
unplaced,
|
||||||
|
} from '../src/features/store-admin/routePlan';
|
||||||
|
|
||||||
|
const line = (s = '') => console.log(s);
|
||||||
|
|
||||||
|
/** Real Suriya Store geography: the RS Puram branch out to four drops. */
|
||||||
|
const ORDERS: OrderRow[] = [
|
||||||
|
{ orderheaderid: 1, orderid: 'N-1', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0284', deliverylong: '77.0120', deliverycustomer: 'Peelamedu' },
|
||||||
|
{ orderheaderid: 2, orderid: 'N-2', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0050', deliverylong: '76.9508', deliverycustomer: 'RS Puram' },
|
||||||
|
{ orderheaderid: 3, orderid: 'N-3', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '10.9877', deliverylong: '76.9620', deliverycustomer: 'Ukkadam' },
|
||||||
|
{ orderheaderid: 4, orderid: 'N-4', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0183', deliverylong: '76.9724', deliverycustomer: 'Gandhipuram' },
|
||||||
|
// No coordinates — must come back as unplaced rather than vanishing.
|
||||||
|
{ orderheaderid: 5, orderid: 'N-5', deliverycustomer: 'No location on file' },
|
||||||
|
] as OrderRow[];
|
||||||
|
|
||||||
|
const RIDER: RiderInfo = { userid: 9701, fullname: 'Meera Raj', contactno: '9000000001' };
|
||||||
|
|
||||||
|
line('─'.repeat(74));
|
||||||
|
line('1 · SEQUENCE — send in a deliberately bad order, see what comes back');
|
||||||
|
line('─'.repeat(74));
|
||||||
|
|
||||||
|
const { routable, unroutable } = splitRoutable(ORDERS);
|
||||||
|
const stops = await optimiserApi.sequence(routable);
|
||||||
|
let plan = planFromSequence(stops, RIDER);
|
||||||
|
|
||||||
|
for (const stop of plan.riders[0]?.orders ?? []) {
|
||||||
|
line(
|
||||||
|
` ${String(stop.step).padStart(2)} ${(stop.orderid ?? '').padEnd(5)} ` +
|
||||||
|
`${(stop.deliverycustomer ?? '').padEnd(22)} ` +
|
||||||
|
`${String(stop.previouskms ?? '').padStart(5)} km ` +
|
||||||
|
`cum ${String(stop.cumulativekms ?? '').padStart(5)} km ` +
|
||||||
|
`eta ${stop.eta ?? '-'}m actualkms ${stop.actualkms ?? '-'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
line(` total ${planKms(plan).toFixed(1)} km`);
|
||||||
|
|
||||||
|
const missed = [...unroutable, ...unplaced(routable, stops)];
|
||||||
|
line(` unplaced: ${missed.length ? missed.map((o) => o.orderid).join(', ') : 'none'}`);
|
||||||
|
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
|
||||||
|
|
||||||
|
line();
|
||||||
|
line('─'.repeat(74));
|
||||||
|
line('2 · EDIT — move the first stop to last, which breaks the step numbers');
|
||||||
|
line('─'.repeat(74));
|
||||||
|
|
||||||
|
plan = reorderStops(plan, RIDER.userid, 0, (plan.riders[0]?.orders.length ?? 1) - 1);
|
||||||
|
line(` dirty rounds: ${[...plan.dirty].join(', ')}`);
|
||||||
|
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')} <- out of order`);
|
||||||
|
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'NO'}`);
|
||||||
|
line(` reason: ${commitProblem(plan)}`);
|
||||||
|
|
||||||
|
line();
|
||||||
|
line('─'.repeat(74));
|
||||||
|
line('3 · RECONCILE — the service repairs the step numbers');
|
||||||
|
line('─'.repeat(74));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await optimiserApi.reconcile(dirtyRiders(plan));
|
||||||
|
plan = applyReconcile(plan, response);
|
||||||
|
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')}`);
|
||||||
|
line(` dirty rounds: ${plan.dirty.size === 0 ? 'none' : [...plan.dirty].join(', ')}`);
|
||||||
|
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
|
||||||
|
} catch (error) {
|
||||||
|
line(` reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
line(` commit still blocked? ${commitProblem(plan) !== '' ? 'YES — correct' : 'NO — WRONG'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
line();
|
||||||
150
scripts/db.mjs
150
scripts/db.mjs
@@ -32,7 +32,53 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const ENDPOINT = process.env.HASURA_URL ?? 'https://api.workolik.com/v1/graphql';
|
/**
|
||||||
|
* Where Hasura actually lives, discovered rather than assumed.
|
||||||
|
*
|
||||||
|
* The first version of this hardcoded `/v1/graphql` at the host root and got a
|
||||||
|
* 404. The old console's proxy is the clue it should have read: it rewrites
|
||||||
|
* `/hasura` to `/api/rest/`, which means Hasura is mounted under `/api`, not at
|
||||||
|
* the root. Rather than swap one guess for another, this tries the candidates
|
||||||
|
* and uses whichever answers.
|
||||||
|
*
|
||||||
|
* Override with HASURA_URL if it moves again — pass the full GraphQL URL.
|
||||||
|
*/
|
||||||
|
const ENDPOINT_CANDIDATES = process.env.HASURA_URL
|
||||||
|
? [process.env.HASURA_URL]
|
||||||
|
: [
|
||||||
|
'https://api.workolik.com/api/v1/graphql',
|
||||||
|
'https://api.workolik.com/v1/graphql',
|
||||||
|
'https://api.workolik.com/hasura/v1/graphql',
|
||||||
|
];
|
||||||
|
|
||||||
|
let ENDPOINT = ENDPOINT_CANDIDATES[0];
|
||||||
|
|
||||||
|
/** Finds the first candidate that answers a trivial query. */
|
||||||
|
async function resolveEndpoint() {
|
||||||
|
for (const candidate of ENDPOINT_CANDIDATES) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(candidate, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
|
||||||
|
body: JSON.stringify({ query: '{ __typename }' }),
|
||||||
|
});
|
||||||
|
if (!response.ok) continue;
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (payload && !payload.errors) {
|
||||||
|
ENDPOINT = candidate;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
'Could not find the Hasura GraphQL endpoint. Tried:\n' +
|
||||||
|
ENDPOINT_CANDIDATES.map((c) => ` ${c}`).join('\n') +
|
||||||
|
'\nSet HASURA_URL to the full GraphQL URL and run again.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where the old console keeps its gitignored secret, relative to this repo. */
|
/** Where the old console keeps its gitignored secret, relative to this repo. */
|
||||||
const ENV_CANDIDATES = [
|
const ENV_CANDIDATES = [
|
||||||
@@ -223,6 +269,103 @@ async function sql(statement) {
|
|||||||
console.log(`\n${Math.max(0, rows.length - 1)} rows`);
|
console.log(`\n${Math.max(0, rows.length - 1)} rows`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the app shows fewer products than the console does.
|
||||||
|
*
|
||||||
|
* node scripts/db.mjs appgap <tenantid> <locationid>
|
||||||
|
*
|
||||||
|
* `getproductsbysubcategory` is what the customer app browses with, and three
|
||||||
|
* separate conditions decide whether a product survives it. None of them is an
|
||||||
|
* error and none of them is logged — a product that fails any one simply is not
|
||||||
|
* in the response, which is why the console can be full and the app empty.
|
||||||
|
*
|
||||||
|
* A. `WHERE a.categoryid = ?` — the caller passes 2, and the filter is not
|
||||||
|
* optional (`productRepository.go:865`). A product in any other category is
|
||||||
|
* invisible to this endpoint no matter what else is true of it.
|
||||||
|
*
|
||||||
|
* B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. A product
|
||||||
|
* with no row for THIS outlet joins to NULL, and the WHERE then drops it.
|
||||||
|
* Being in the catalogue is not the same as being on a shelf: something has
|
||||||
|
* to write `productlocations`, and nothing does that automatically.
|
||||||
|
*
|
||||||
|
* C. The grouping in `GetProductsBySubcategory` walks the real subcategories
|
||||||
|
* of category 2 and collects products matching each, then sweeps up
|
||||||
|
* everything with `subcategoryid = 0` as "Uncategorized". A product whose
|
||||||
|
* subcategoryid is non-zero but is NOT a subcategory of category 2 matches
|
||||||
|
* neither loop and vanishes — it is in the query results and absent from
|
||||||
|
* the response. This one is worth looking for first, because it looks like
|
||||||
|
* nothing at all.
|
||||||
|
*/
|
||||||
|
async function appgap(tenantid, locationid) {
|
||||||
|
const tid = Number(tenantid);
|
||||||
|
const lid = Number(locationid);
|
||||||
|
if (!tid || !lid) {
|
||||||
|
console.error('Usage: node scripts/db.mjs appgap <tenantid> <locationid>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphQL, not `run_sql`.
|
||||||
|
//
|
||||||
|
// `run_sql` lives on Hasura's `/v2/query` admin API, which answered 404 here —
|
||||||
|
// it is disabled on managed instances and behind a different path on others.
|
||||||
|
// Three ordinary queries and the bucketing done in JS needs none of that, and
|
||||||
|
// works on any Hasura the admin secret can reach.
|
||||||
|
const data = await gql(
|
||||||
|
`query ($tid: Int!, $lid: Int!) {
|
||||||
|
products(where: { tenantid: { _eq: $tid } }) {
|
||||||
|
productid productname categoryid subcategoryid
|
||||||
|
}
|
||||||
|
productlocations(where: { tenantid: { _eq: $tid }, locationid: { _eq: $lid } }) {
|
||||||
|
productid
|
||||||
|
}
|
||||||
|
productsubcategories(where: { categoryid: { _eq: 2 } }) {
|
||||||
|
subcategoryid subcategoryname
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ tid, lid },
|
||||||
|
);
|
||||||
|
|
||||||
|
const products = data.products ?? [];
|
||||||
|
const listed = new Set((data.productlocations ?? []).map((row) => row.productid));
|
||||||
|
const realSubs = new Map(
|
||||||
|
(data.productsubcategories ?? []).map((row) => [row.subcategoryid, row.subcategoryname]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
console.log(`Tenant ${tid} has no products at all.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
const examples = new Map();
|
||||||
|
for (const product of products) {
|
||||||
|
let reason;
|
||||||
|
if (product.categoryid !== 2) {
|
||||||
|
reason = 'A. categoryid is not 2 — the app only asks for category 2';
|
||||||
|
} else if (!listed.has(product.productid)) {
|
||||||
|
reason = 'B. not listed at this outlet — no productlocations row';
|
||||||
|
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
|
||||||
|
reason = 'C. subcategoryid is not a real subcategory of 2 — silently dropped';
|
||||||
|
} else {
|
||||||
|
reason = 'OK. should appear in the app';
|
||||||
|
}
|
||||||
|
buckets.set(reason, (buckets.get(reason) ?? 0) + 1);
|
||||||
|
if (!examples.has(reason)) examples.set(reason, product);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${products.length} products on tenant ${tid}\n`);
|
||||||
|
const ordered = [...buckets.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
for (const [reason, count] of ordered) {
|
||||||
|
const sample = examples.get(reason);
|
||||||
|
console.log(` ${String(count).padStart(5)} ${reason}`);
|
||||||
|
console.log(
|
||||||
|
` e.g. ${sample.productname} (id ${sample.productid}, category ${sample.categoryid}, subcategory ${sample.subcategoryid})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Dispatch ─────────────────────────────────────────────────────────────── */
|
/* ── Dispatch ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const [command, ...rest] = process.argv.slice(2);
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
@@ -232,6 +375,7 @@ const COMMANDS = {
|
|||||||
user: () => user(rest[0]),
|
user: () => user(rest[0]),
|
||||||
setpw: () => setpw(rest[0], rest[1]),
|
setpw: () => setpw(rest[0], rest[1]),
|
||||||
sql: () => sql(rest.join(' ')),
|
sql: () => sql(rest.join(' ')),
|
||||||
|
appgap: () => appgap(rest[0], rest[1]),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!command || !COMMANDS[command]) {
|
if (!command || !COMMANDS[command]) {
|
||||||
@@ -243,9 +387,13 @@ if (!command || !COMMANDS[command]) {
|
|||||||
' user <email> show an account (never prints the password)',
|
' user <email> show an account (never prints the password)',
|
||||||
' setpw <email> <password> set a password on an account that has none',
|
' setpw <email> <password> set a password on an account that has none',
|
||||||
' sql "<select ...>" read-only SQL',
|
' sql "<select ...>" read-only SQL',
|
||||||
|
' appgap <tenant> <outlet> why the app shows fewer products than the console',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
process.exit(command ? 1 : 0);
|
process.exit(command ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Locate Hasura before anything talks to it.
|
||||||
|
await resolveEndpoint();
|
||||||
|
|
||||||
await COMMANDS[command]();
|
await COMMANDS[command]();
|
||||||
|
|||||||
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');
|
||||||
211
scripts/refileCategories.ts
Normal file
211
scripts/refileCategories.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Puts a tenant's existing products into the aisles the customer app displays.
|
||||||
|
*
|
||||||
|
* Everything imported before this carries `subcategoryid: 0`, which the app
|
||||||
|
* renders as one heading called "Uncategorized" holding the entire shop —
|
||||||
|
* measured on live tenant 1135/1166 — while the catalogue has known all along
|
||||||
|
* that an Aachi masala is Spices & Masalas. This reads that answer back, folds
|
||||||
|
* it into one of the app's ten aisles (`appAisle.ts`) and writes it.
|
||||||
|
*
|
||||||
|
* `categoryid` is deliberately NOT changed. `getproductsbysubcategory` filters
|
||||||
|
* on it with the 2 the app sends, so a per-product categoryid does not label a
|
||||||
|
* product, it removes it from the app entirely.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/refileCategories.ts 1147 # dry run, writes nothing
|
||||||
|
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
|
||||||
|
*
|
||||||
|
* ── How a product is matched to its catalogue row ───────────────────────────
|
||||||
|
*
|
||||||
|
* On `brand` + `productsku`, never on `catalogueid`. The catalogue renumbers
|
||||||
|
* its ids on every re-scrape — 11 of 19 links were already broken when that was
|
||||||
|
* last measured — so a product's stored `catalogueid` points at whatever
|
||||||
|
* happens to sit at that number today, which may be a different product.
|
||||||
|
*
|
||||||
|
* ── What it does when there is no catalogue row ─────────────────────────────
|
||||||
|
*
|
||||||
|
* Falls back to the same deterministic ladder the import uses, so a product
|
||||||
|
* typed in by hand is filed too rather than left behind. The report says which
|
||||||
|
* source decided each one, because "the catalogue says so" and "we guessed from
|
||||||
|
* the name" are different levels of confidence and an operator reviewing 300
|
||||||
|
* rows deserves to know which is which.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { catalogueApi } from '../src/api/catalogue';
|
||||||
|
import { productsApi } from '../src/api/products';
|
||||||
|
import {
|
||||||
|
categoryForCatalogueProduct,
|
||||||
|
UNKNOWN_CATEGORY,
|
||||||
|
} from '../src/features/store-admin/productCategory';
|
||||||
|
import { aisleForCategory, aisleIdsFrom } from '../src/features/store-admin/appAisle';
|
||||||
|
import { APP_BROWSE_CATEGORY } from '../src/features/catalogue/tenantCategories';
|
||||||
|
import type { CatalogueProduct, Product } from '../src/api/types';
|
||||||
|
|
||||||
|
const tenantid = Number(process.argv[2]);
|
||||||
|
const isApply = process.argv.includes('--apply');
|
||||||
|
|
||||||
|
if (!tenantid) {
|
||||||
|
console.error('Usage: npx tsx scripts/refileCategories.ts <tenantid> [--apply]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source = 'catalogue' | 'ladder';
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
product: Product;
|
||||||
|
/** The subcategory the product sits in today — 0 for everything, so far. */
|
||||||
|
from: number;
|
||||||
|
/** One of the catalogue's 31, for the report. */
|
||||||
|
category: string;
|
||||||
|
/** One of the app's ten aisles, or null when the category folds to none. */
|
||||||
|
aisle: string | null;
|
||||||
|
source: Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every catalogue row for one brand, keyed by SKU. One request per brand. */
|
||||||
|
async function catalogueByBrand(brand: string): Promise<Map<string, CatalogueProduct>> {
|
||||||
|
const out = new Map<string, CatalogueProduct>();
|
||||||
|
for (let page = 0; page < 20; page += 1) {
|
||||||
|
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: 500 });
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.product_sku) out.set(row.product_sku.trim().toLowerCase(), row);
|
||||||
|
}
|
||||||
|
if (rows.length < 500) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const products = await productsApi.locationProducts({ tenantid, locationid: 0, pagesize: 2000 });
|
||||||
|
console.log(`${products.length} products for tenant ${tenantid}\n`);
|
||||||
|
|
||||||
|
// One catalogue read per distinct brand, not one per product.
|
||||||
|
const brands = [...new Set(products.map((p) => (p.productbrand ?? '').trim()).filter(Boolean))];
|
||||||
|
const catalogue = new Map<string, Map<string, CatalogueProduct>>();
|
||||||
|
for (const brand of brands) {
|
||||||
|
try {
|
||||||
|
catalogue.set(brand.toLowerCase(), await catalogueByBrand(brand));
|
||||||
|
} catch {
|
||||||
|
catalogue.set(brand.toLowerCase(), new Map());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans: Plan[] = [];
|
||||||
|
for (const product of products) {
|
||||||
|
const brand = (product.productbrand ?? '').trim().toLowerCase();
|
||||||
|
const sku = (product.productsku ?? '').trim().toLowerCase();
|
||||||
|
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
|
||||||
|
|
||||||
|
/*
|
||||||
|
The catalogue's answer only when it is one of the 31.
|
||||||
|
|
||||||
|
It carries names the platform does not have — "Food - Mixes", "Pickles &
|
||||||
|
Chutneys", "Dairy - Desserts" on about a third of the rows sampled — and
|
||||||
|
taking those verbatim would file a shop's products under aisles the app
|
||||||
|
cannot browse and no other shop shares. The same gate the import uses.
|
||||||
|
*/
|
||||||
|
const verdict = categoryForCatalogueProduct({
|
||||||
|
catalogueCategory: row?.category ?? '',
|
||||||
|
title: product.productname ?? '',
|
||||||
|
description: product.productdesc ?? '',
|
||||||
|
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
|
||||||
|
});
|
||||||
|
|
||||||
|
plans.push({
|
||||||
|
product,
|
||||||
|
from: product.subcategoryid ?? 0,
|
||||||
|
category: verdict.category,
|
||||||
|
aisle: aisleForCategory(verdict.category),
|
||||||
|
source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The aisle ids, by name, from the platform's own list — see `appAisle.ts`
|
||||||
|
// for why they are matched on the name and not remembered as numbers.
|
||||||
|
const aisleIds = aisleIdsFrom(
|
||||||
|
await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
const byAisle: Record<string, number> = {};
|
||||||
|
let unchanged = 0;
|
||||||
|
const writes: Plan[] = [];
|
||||||
|
const orphans: Plan[] = [];
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
if (!plan.aisle) {
|
||||||
|
// Only worth reporting if the product has no aisle ALREADY. Several were
|
||||||
|
// filed by hand long before any of this, and listing those as "would stay
|
||||||
|
// under Uncategorized" says the opposite of what is true.
|
||||||
|
if (plan.from === 0) orphans.push(plan);
|
||||||
|
else unchanged += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0;
|
||||||
|
if (to === plan.from) {
|
||||||
|
unchanged += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writes.push(plan);
|
||||||
|
const key = `${plan.aisle} ← ${plan.category} (${plan.source})`;
|
||||||
|
byAisle[key] = (byAisle[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle
|
||||||
|
`);
|
||||||
|
Object.entries(byAisle)
|
||||||
|
.sort((x, y) => y[1] - x[1])
|
||||||
|
.forEach(([name, count]) => console.log(` ${String(count).padStart(4)} ${name}`));
|
||||||
|
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`
|
||||||
|
${orphans.length} have no aisle and would stay under the app's "Uncategorized":`,
|
||||||
|
);
|
||||||
|
orphans
|
||||||
|
.slice(0, 10)
|
||||||
|
.forEach((p) =>
|
||||||
|
console.log(
|
||||||
|
` ${(p.product.productname ?? '').slice(0, 44).padEnd(46)} ${p.category}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const unknown = orphans.filter((p) => p.category === UNKNOWN_CATEGORY).length;
|
||||||
|
if (unknown > 0) console.log(` (${unknown} of them could not be identified at all)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nA sample of what changes:');
|
||||||
|
writes.slice(0, 12).forEach((p) => {
|
||||||
|
console.log(
|
||||||
|
` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.aisle} (${p.source})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isApply) {
|
||||||
|
console.log('\nDry run. Nothing was written. Re-run with --apply to write.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nWriting…');
|
||||||
|
/*
|
||||||
|
One call for the whole tenant, not one request per product.
|
||||||
|
|
||||||
|
`recategorise` writes the category and subcategory columns only, and is scoped
|
||||||
|
by tenantid on the server, so it cannot reach another merchant's rows and cannot overwrite a price the
|
||||||
|
way a whole-row update would. `PUT /products/update` was the obvious candidate
|
||||||
|
and is the wrong one: it updates `productlocations.status` and never touches
|
||||||
|
the products table at all.
|
||||||
|
*/
|
||||||
|
const updates = writes
|
||||||
|
.map((plan) => ({
|
||||||
|
productid: plan.product.productid,
|
||||||
|
// Unchanged, and that is the point: it is the app's filter, not a label.
|
||||||
|
categoryid: APP_BROWSE_CATEGORY,
|
||||||
|
subcategoryid: aisleIds.get((plan.aisle ?? '').toLowerCase()) ?? 0,
|
||||||
|
}))
|
||||||
|
.filter((row) => row.subcategoryid > 0);
|
||||||
|
|
||||||
|
const skipped = writes.length - updates.length;
|
||||||
|
const result = await productsApi.recategorise(tenantid, updates);
|
||||||
|
console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`);
|
||||||
|
if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their aisle.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
89
src/App.tsx
89
src/App.tsx
@@ -3,8 +3,9 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||||
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
import { RequireRole, useAuth } from '@/auth/AuthContext';
|
||||||
import { HOME_ROUTE } from '@/auth/roles';
|
import { HOME_ROUTE } from '@/auth/roles';
|
||||||
|
import { withStaleChunkRecovery } from '@/lib/staleChunk';
|
||||||
import { LoginPage } from '@/features/auth/LoginPage';
|
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 { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
|
||||||
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
||||||
|
|
||||||
@@ -15,21 +16,37 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell';
|
|||||||
* means every visitor downloads the spreadsheet importer to look at a dashboard.
|
* means every visitor downloads the spreadsheet importer to look at a dashboard.
|
||||||
* That is the one thing from it worth deliberately not copying.
|
* That is the one thing from it worth deliberately not copying.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Split pages recover from a deploy that lands mid-session.
|
||||||
|
*
|
||||||
|
* `withStaleChunkRecovery` is the difference between "Inventory is broken" and
|
||||||
|
* a reload nobody notices: the hashed filename this tab remembers stops
|
||||||
|
* existing the moment a new build ships, and the import 404s. See
|
||||||
|
* `lib/staleChunk.ts` — real errors still surface, only the missing chunk is
|
||||||
|
* retried.
|
||||||
|
*/
|
||||||
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
|
||||||
lazy(() => loader().then((module) => ({ default: module[key] })));
|
lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] }))));
|
||||||
|
|
||||||
const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage'));
|
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
|
||||||
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
|
its stylesheet, which nobody who never opens the fleet map should download. */
|
||||||
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
|
|
||||||
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
|
|
||||||
|
|
||||||
const ConsolePage = named('ConsolePage', () => import('@/features/store-admin/pages/ConsolePage'));
|
/* 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
|
||||||
|
below therefore render the same component, not two copies of one board. */
|
||||||
|
const ConsolePage = named('ConsolePage', () => import('@/features/console/ConsolePage'));
|
||||||
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
|
const SalesPage = named('SalesPage', () => import('@/features/store-admin/pages/SalesPage'));
|
||||||
|
const DispatchPage = named('DispatchPage', () => import('@/features/store-admin/pages/DispatchPage'));
|
||||||
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
|
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
|
||||||
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
|
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
|
||||||
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
|
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
|
||||||
const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage'));
|
const UsersPage = named('UsersPage', () => import('@/features/store-admin/pages/UsersPage'));
|
||||||
const TerminalsPage = named('TerminalsPage', () => import('@/features/store-admin/pages/TerminalsPage'));
|
/* The counters board. Both workspaces render it; it reads its own scope from
|
||||||
|
BranchScope, which pins a store user to their own outlet. */
|
||||||
|
const TerminalsPage = named('CountersPage', () => import('@/features/store-admin/pages/CountersPage'));
|
||||||
|
const AdminUploadsPage = named('UploadsPage', () => import('@/features/store-admin/pages/UploadsPage'));
|
||||||
|
const ShopProfilePage = named('ShopProfilePage', () => import('@/features/store-admin/pages/ShopProfilePage'));
|
||||||
|
const OnboardingPage = named('OnboardingPage', () => import('@/features/onboarding/OnboardingPage'));
|
||||||
|
|
||||||
/* The Store user workspace reuses the merchant's four pages, pinned to one
|
/* The Store user workspace reuses the merchant's four pages, pinned to one
|
||||||
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
|
branch by `BranchScopeProvider pin=`. Only what a shop does differently is
|
||||||
@@ -38,6 +55,8 @@ const StoreProductsPage = named('StoreProductsPage', () => import('@/features/st
|
|||||||
const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage'));
|
const StoreCustomersPage = named('StoreCustomersPage', () => import('@/features/store-user/pages/StoreCustomersPage'));
|
||||||
const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage'));
|
const StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage'));
|
||||||
const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage'));
|
const StoreAccountPage = named('StoreAccountPage', () => import('@/features/store-user/pages/StoreAccountPage'));
|
||||||
|
const StoreSetupPage = named('StoreSetupPage', () => import('@/features/store-user/pages/StoreSetupPage'));
|
||||||
|
const StoreUploadsPage = named('StoreUploadsPage', () => import('@/features/store-user/pages/StoreUploadsPage'));
|
||||||
|
|
||||||
function RouteFallback() {
|
function RouteFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -61,34 +80,25 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<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 */}
|
{/* Nearle staff have their own application.
|
||||||
<Route
|
|
||||||
path="/nearle"
|
The platform workspace lived here until the two consoles were split.
|
||||||
element={
|
It is now `nearle-platform`, a separate repository deployed on its own
|
||||||
<RequireRole role="nearle-admin">
|
domain, and this one serves merchants and their branch users only.
|
||||||
<Suspense fallback={<RouteFallback />}>
|
|
||||||
<NearleAdminShell />
|
`/nearle/*` is therefore not a protected path here — it is not a path
|
||||||
</Suspense>
|
at all, and falls to the catch-all like any other unknown URL. A
|
||||||
</RequireRole>
|
`nearle-admin` account cannot sign in either: `login` and `restore`
|
||||||
}
|
refuse it before a session is written. */}
|
||||||
>
|
|
||||||
<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 />} />
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
|
||||||
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
@@ -102,6 +112,7 @@ export function App() {
|
|||||||
<Route index element={<Navigate to="/admin/console" replace />} />
|
<Route index element={<Navigate to="/admin/console" replace />} />
|
||||||
<Route path="console" element={<ConsolePage />} />
|
<Route path="console" element={<ConsolePage />} />
|
||||||
<Route path="sales" element={<SalesPage />} />
|
<Route path="sales" element={<SalesPage />} />
|
||||||
|
<Route path="dispatch" element={<DispatchPage />} />
|
||||||
<Route path="inventory" element={<InventoryPage />} />
|
<Route path="inventory" element={<InventoryPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="branches/new" element={<OnboardBranchPage />} />
|
<Route path="branches/new" element={<OnboardBranchPage />} />
|
||||||
@@ -109,6 +120,9 @@ export function App() {
|
|||||||
anyone works from day to day. See `AppShellProps.manageItems`. */}
|
anyone works from day to day. See `AppShellProps.manageItems`. */}
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="terminals" element={<TerminalsPage />} />
|
<Route path="terminals" element={<TerminalsPage />} />
|
||||||
|
<Route path="uploads" element={<AdminUploadsPage />} />
|
||||||
|
<Route path="profile" element={<ShopProfilePage />} />
|
||||||
|
<Route path="onboarding" element={<OnboardingPage />} />
|
||||||
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
{/* Catches `/admin/dashboard` and anything else that does not resolve.
|
||||||
Without this, an unknown sub-path escapes to the global `*`, which
|
Without this, an unknown sub-path escapes to the global `*`, which
|
||||||
redirects to this role's HOME_ROUTE — and if that is itself an
|
redirects to this role's HOME_ROUTE — and if that is itself an
|
||||||
@@ -133,16 +147,25 @@ export function App() {
|
|||||||
<Route path="console" element={<ConsolePage />} />
|
<Route path="console" element={<ConsolePage />} />
|
||||||
<Route path="products" element={<StoreProductsPage />} />
|
<Route path="products" element={<StoreProductsPage />} />
|
||||||
<Route path="sales" element={<SalesPage />} />
|
<Route path="sales" element={<SalesPage />} />
|
||||||
|
<Route path="dispatch" element={<DispatchPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
{/* Reached from the account menu. A shop does not commission outlets,
|
{/* Reached from the account menu. A shop does not commission outlets,
|
||||||
so there is deliberately no `branches/new` here. */}
|
so there is deliberately no `branches/new` here. */}
|
||||||
<Route path="customers" element={<StoreCustomersPage />} />
|
<Route path="customers" element={<StoreCustomersPage />} />
|
||||||
<Route path="terminals" element={<TerminalsPage />} />
|
<Route path="terminals" element={<TerminalsPage />} />
|
||||||
<Route path="staff" element={<StoreStaffPage />} />
|
<Route path="staff" element={<StoreStaffPage />} />
|
||||||
|
<Route path="uploads" element={<StoreUploadsPage />} />
|
||||||
<Route path="account" element={<StoreAccountPage />} />
|
<Route path="account" element={<StoreAccountPage />} />
|
||||||
|
{/* The branch user's half of setup. Same route shape as the merchant's
|
||||||
|
`/admin/onboarding`, so the two logins are reached the same way. */}
|
||||||
|
<Route path="setup" element={<StoreSetupPage />} />
|
||||||
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
<Route path="*" element={<Navigate to="/store/console" replace />} />
|
||||||
</Route>
|
</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
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
|
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 });
|
||||||
|
}
|
||||||
@@ -36,6 +36,30 @@ export const catalogueApi = {
|
|||||||
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
|
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
|
||||||
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
|
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every `image_id` → catalogue row id for one brand, in as few calls as the
|
||||||
|
* page size allows.
|
||||||
|
*
|
||||||
|
* Built for reconciling an ingest manifest. Resolving those one at a time is a
|
||||||
|
* request per product — a 500-row sheet would open 500 connections from a
|
||||||
|
* shop's browser — while a brand is at most a few hundred rows and comes back
|
||||||
|
* in one or two pages.
|
||||||
|
*
|
||||||
|
* `pagesize` is deliberately large but bounded, and paging stops on a short
|
||||||
|
* page rather than trusting a total the list endpoint does not return.
|
||||||
|
*/
|
||||||
|
idsByImageId: async (brand: string, pageSize = 500): Promise<Map<string, number>> => {
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
for (let page = 0; page < 20; page += 1) {
|
||||||
|
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: pageSize });
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.image_id && typeof row.id === 'number') out.set(row.image_id, row.id);
|
||||||
|
}
|
||||||
|
if (rows.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
/** Requires a brand — the backend reads categories from one brand's table. */
|
/** Requires a brand — the backend reads categories from one brand's table. */
|
||||||
categories: (brand: string) =>
|
categories: (brand: string) =>
|
||||||
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
|
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
|
||||||
@@ -50,6 +74,24 @@ export const catalogueApi = {
|
|||||||
product: (brand: string, sku: string) =>
|
product: (brand: string, sku: string) =>
|
||||||
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
|
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One catalogue row by the id the ingest pipeline treats as canonical.
|
||||||
|
*
|
||||||
|
* An ingest run reports what it wrote as a manifest of `image_id` values, and
|
||||||
|
* `importcatalogueproduct` addresses products by `catalogueid` — the row id.
|
||||||
|
* This is the only bridge between the two, and without it a manifest could
|
||||||
|
* only be matched on the product NAME, which the owning team warns silently
|
||||||
|
* creates duplicates rather than updating.
|
||||||
|
*
|
||||||
|
* Prefer `productsByBrand` below when resolving more than a handful: this is
|
||||||
|
* one request per product.
|
||||||
|
*/
|
||||||
|
productByImageId: (brand: string, imageId: string) =>
|
||||||
|
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproductbyimageid`, {
|
||||||
|
brand,
|
||||||
|
image_id: imageId,
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `(brand, catalogueid)` pairs this tenant has already imported, for
|
* The `(brand, catalogueid)` pairs this tenant has already imported, for
|
||||||
* badging "Imported" in the browser. Called without `brand` because the list
|
* badging "Imported" in the browser. Called without `brand` because the list
|
||||||
@@ -59,7 +101,37 @@ export const catalogueApi = {
|
|||||||
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
|
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Key for the imported-refs lookup. Both halves, always. */
|
/**
|
||||||
|
* Key for the imported-refs lookup.
|
||||||
|
*
|
||||||
|
* `image_id` when there is one, and the brand-qualified id only as a fallback.
|
||||||
|
* The order matters: the catalogue is rebuilt by scrape and renumbered every
|
||||||
|
* time, so a tick placed by `catalogueid` lands on whatever product now holds
|
||||||
|
* that number — or, far more often, on nothing. Eleven of the nineteen links on
|
||||||
|
* the platform were in that state on 2026-08-31, which showed rows a shop
|
||||||
|
* really held as NOT imported and invited someone to import them again.
|
||||||
|
*
|
||||||
|
* `image_id` is the key the catalogue itself deduplicates on and survives both
|
||||||
|
* a renumber and a rename.
|
||||||
|
*/
|
||||||
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
|
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
|
||||||
|
const imageId = 'catalogueid' in ref ? ref.imageid : ref.image_id;
|
||||||
|
if (imageId) return `img:${imageId}`;
|
||||||
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
|
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every key one imported ref can be recognised by.
|
||||||
|
*
|
||||||
|
* A ref carries both halves during the changeover — the stable key it has just
|
||||||
|
* acquired, and the id it was imported under years of scrapes ago. Emitting
|
||||||
|
* both means a browse screen keeps matching products that have not been
|
||||||
|
* relinked yet, instead of showing a shop's own stock as missing until someone
|
||||||
|
* runs the repair.
|
||||||
|
*/
|
||||||
|
export function catalogueKeysOf(ref: CatalogueRef): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (ref.imageid) keys.push(`img:${ref.imageid}`);
|
||||||
|
if (ref.catalogueid) keys.push(`${ref.brand}:${ref.catalogueid}`);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|||||||
85
src/api/catalogueKey.test.ts
Normal file
85
src/api/catalogueKey.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Which key a catalogue product is recognised by.
|
||||||
|
*
|
||||||
|
* This decides whether the browse screen shows a product as already imported.
|
||||||
|
* Getting it wrong is not cosmetic: a shop's own stock shown as missing gets
|
||||||
|
* imported a second time, and the shop ends up with duplicates.
|
||||||
|
*
|
||||||
|
* The reason it changed: `catalogueid` is renumbered by every re-scrape.
|
||||||
|
* Pepsico's live ids run 3, 6, 9 … 27, 30 — there is no 19, 25 or 26 — so on
|
||||||
|
* 2026-08-31 eleven of the nineteen links on the platform pointed at rows that
|
||||||
|
* no longer existed. `image_id` is the key the catalogue itself deduplicates on
|
||||||
|
* and survives both a renumber and a rename.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { catalogueKey, catalogueKeysOf } from './catalogue';
|
||||||
|
|
||||||
|
test('a product with a stable key is identified by it, not by its id', () => {
|
||||||
|
assert.equal(
|
||||||
|
catalogueKey({ brand: 'pepsico', id: 27, image_id: 'cheetos_chips_2d6bf74f' } as never),
|
||||||
|
'img:cheetos_chips_2d6bf74f',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
catalogueKey({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' } as never),
|
||||||
|
'img:cheetos_chips_2d6bf74f',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The two sides have to agree. A ref from Fiesta and a product from the catalogue
|
||||||
|
describe the same thing under different field names — `imageid` and `image_id` —
|
||||||
|
and if they produced different keys the tick would never appear at all.
|
||||||
|
*/
|
||||||
|
test('a ref and a catalogue row agree on the key', () => {
|
||||||
|
const fromFiesta = catalogueKey({
|
||||||
|
brand: 'pepsico',
|
||||||
|
catalogueid: 27,
|
||||||
|
imageid: 'cheetos_chips_2d6bf74f',
|
||||||
|
} as never);
|
||||||
|
const fromCatalogue = catalogueKey({
|
||||||
|
brand: 'pepsico',
|
||||||
|
id: 27,
|
||||||
|
image_id: 'cheetos_chips_2d6bf74f',
|
||||||
|
} as never);
|
||||||
|
assert.equal(fromFiesta, fromCatalogue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fallback still has to work: products imported before the column existed
|
||||||
|
// carry only the id, and they are genuinely imported.
|
||||||
|
test('without a stable key the brand-qualified id is used', () => {
|
||||||
|
assert.equal(catalogueKey({ brand: 'dabur', catalogueid: 19 } as never), 'dabur:19');
|
||||||
|
assert.equal(catalogueKey({ brand: 'dabur', id: 19 } as never), 'dabur:19');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Brand-qualified, never bare. Each brand is its own table with its own
|
||||||
|
// sequence, so dabur 19 and pepsico 19 both exist and a bare id would tick the
|
||||||
|
// wrong product.
|
||||||
|
test('the fallback key keeps the brand, because ids repeat across brands', () => {
|
||||||
|
assert.notEqual(
|
||||||
|
catalogueKey({ brand: 'dabur', catalogueid: 19 } as never),
|
||||||
|
catalogueKey({ brand: 'pepsico', catalogueid: 19 } as never),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
During the changeover a ref carries both. Emitting only the stable key would
|
||||||
|
make every not-yet-relinked product read as missing the moment this shipped —
|
||||||
|
turning a silent problem into a visible one on every shop at once.
|
||||||
|
*/
|
||||||
|
test('a ref is recognised by both keys while the changeover runs', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
catalogueKeysOf({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' }),
|
||||||
|
['img:cheetos_chips_2d6bf74f', 'pepsico:27'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ref with only an id still yields its one key', () => {
|
||||||
|
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 19 }), ['dabur:19']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A ref with neither yields nothing rather than a key like "undefined:0" that
|
||||||
|
// would collide with every other broken ref and tick unrelated products.
|
||||||
|
test('a ref with nothing to match on yields no keys at all', () => {
|
||||||
|
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 0 }), []);
|
||||||
|
});
|
||||||
@@ -6,14 +6,83 @@
|
|||||||
* changes. Nothing else in the app calls `fetch`.
|
* changes. Nothing else in the app calls `fetch`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { authHeader, forgetSession, readSessionToken } from '@/auth/token';
|
||||||
import type { FiestaEnvelope } from './types';
|
import type { FiestaEnvelope } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In dev, Vite proxies `/fiesta` -> https://fiesta.nearle.app (see
|
* A 401 on a call we authenticated means the session is over.
|
||||||
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
|
*
|
||||||
* surprises. In production the deployed host is set by VITE_API_BASE.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
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.
|
||||||
|
*
|
||||||
|
* Set in `.env` as `VITE_API_BASE`, so the host is declared in one place rather
|
||||||
|
* than inferred here — Vite compiles it into the bundle at build time and both
|
||||||
|
* `npm run dev` and a deployed build use the same value.
|
||||||
|
*
|
||||||
|
* This module briefly decided the host itself, switching on `import.meta.env.DEV`.
|
||||||
|
* Explicit configuration is better: a rule in code that says "development means
|
||||||
|
* this, production means that" is invisible from the outside, and someone
|
||||||
|
* reading `.env` to find the backend would have found nothing.
|
||||||
|
*
|
||||||
|
* The fallback is the REAL HOST, not the same-origin `/fiesta` prefix it used
|
||||||
|
* to be. That prefix looked like a safe degradation and was not: a platform
|
||||||
|
* that writes its own `.env` into the build context (Dokploy does) erases the
|
||||||
|
* committed `VITE_API_BASE`, and the bundle then aims every call at whatever
|
||||||
|
* domain serves the console — `https://app.nearledaily.com/fiesta/live/api/...`
|
||||||
|
* instead of Fiesta. It kept working only because nginx happens to proxy that
|
||||||
|
* prefix, which is what made the misconfiguration invisible.
|
||||||
|
*
|
||||||
|
* Defaulting to the host means a missing variable can no longer silently
|
||||||
|
* re-point the backend at the console's own domain. `Dockerfile` also passes
|
||||||
|
* `VITE_API_BASE` as a build argument, so the value survives an overwritten
|
||||||
|
* `.env`.
|
||||||
|
*
|
||||||
|
* A trailing slash is stripped: every path below starts with `/`, and
|
||||||
|
* `https://host//live/api/...` is a different URL to the upstream router.
|
||||||
|
*
|
||||||
|
* Override per machine with `.env.local`, which is gitignored — set it to
|
||||||
|
* `/fiesta` to route through the dev proxy or nginx instead.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Optional-chained for the same reason `ingest.ts` is: `import.meta.env` is
|
||||||
|
* Vite's, and it is undefined anywhere Vite is not — the test runner included.
|
||||||
|
* Without the `?.` this line throws on import, so every test that so much as
|
||||||
|
* names a module reaching this one fails before it runs, with a TypeError
|
||||||
|
* pointing here rather than at the test. The value already has a fallback; this
|
||||||
|
* only stops the read itself from being fatal.
|
||||||
|
*/
|
||||||
|
const configuredBase = (import.meta.env?.['VITE_API_BASE'] ?? '').trim();
|
||||||
|
|
||||||
|
export const API_BASE = (configuredBase || 'https://fiesta.nearle.app').replace(
|
||||||
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
/** Every console route lives under this prefix. */
|
/** Every console route lives under this prefix. */
|
||||||
export const WEB = '/live/api/v1/web';
|
export const WEB = '/live/api/v1/web';
|
||||||
@@ -94,7 +163,17 @@ interface RequestOptions {
|
|||||||
signal?: AbortSignal;
|
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;
|
const { method = 'GET', params, body, signal } = options;
|
||||||
|
|
||||||
// There is exactly one path out of this function and it goes to `fetch`.
|
// There is exactly one path out of this function and it goes to `fetch`.
|
||||||
@@ -108,7 +187,10 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
|
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method,
|
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,
|
signal: signal ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,6 +221,10 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
endDeadSession(path);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok || envelope.status === false) {
|
if (!response.ok || envelope.status === false) {
|
||||||
throw new FiestaError(
|
throw new FiestaError(
|
||||||
envelope.message ?? `Request failed (HTTP ${response.status})`,
|
envelope.message ?? `Request failed (HTTP ${response.status})`,
|
||||||
@@ -147,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
|
return envelope;
|
||||||
// `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.
|
/**
|
||||||
|
* 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;
|
return (envelope.details ?? envelope.data) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +260,7 @@ async function requestEnvelope<T>(
|
|||||||
const { method = 'GET', params, body } = options;
|
const { method = 'GET', params, body } = options;
|
||||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
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) {
|
if (body !== undefined) {
|
||||||
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
|
||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
@@ -212,6 +307,16 @@ export const api = {
|
|||||||
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||||
request<T>(path, { method: 'POST', body, params }),
|
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>) =>
|
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||||
request<T>(path, { method: 'PUT', body, params }),
|
request<T>(path, { method: 'PUT', body, params }),
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,24 @@ export interface CustomerInfo {
|
|||||||
deliverylocationid?: number;
|
deliverylocationid?: number;
|
||||||
tenantlocationid?: number;
|
tenantlocationid?: number;
|
||||||
applocationid?: 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;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
490
src/api/deliveries.ts
Normal file
490
src/api/deliveries.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import { api, WEB } from './client';
|
||||||
|
import type { RiderInfo } from './types';
|
||||||
|
import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliveries — creating them, moving them along, and finding a rider.
|
||||||
|
*
|
||||||
|
* Separate from `insights.ts`, which only READS deliveries. The split is the
|
||||||
|
* same one the backend makes: `getdeliveries` answers "what is out there", and
|
||||||
|
* these three change it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rider has no device registered.
|
||||||
|
*
|
||||||
|
* Its own error type because the remedy differs from a transport failure: the
|
||||||
|
* rider must open the app and sign in, not retry. Collapsed into a generic
|
||||||
|
* "notification failed", an operator assumes the network is at fault and tries
|
||||||
|
* again forever.
|
||||||
|
*/
|
||||||
|
export class RiderNotReachableError extends Error {
|
||||||
|
constructor(message = 'This rider has no device registered, so they were not told.') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'RiderNotReachableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the rider is told. Kept together so the wording stays consistent. */
|
||||||
|
export const RIDER_MESSAGE = {
|
||||||
|
assigned: (count: number) =>
|
||||||
|
count === 1
|
||||||
|
? 'An order has been assigned to you. Kindly accept and process the delivery.'
|
||||||
|
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which fleet to ask for. One of these, in this order of preference.
|
||||||
|
*
|
||||||
|
* `getriders` scopes by applocation, partner or tenant. It used to be called
|
||||||
|
* with the region ALONE, which asks "who is on duty in this city" — so a
|
||||||
|
* merchant's assign picker offered every on-duty rider in Coimbatore, including
|
||||||
|
* other merchants' own riders and every other partner's.
|
||||||
|
*
|
||||||
|
* Measured 2026-09-09: 118 riders across three regions, and 117 of them belong
|
||||||
|
* to a delivery partner — 75 to partner 44 alone. Exactly one rider on the
|
||||||
|
* platform is a merchant's own. So the region scope was not a harmless default;
|
||||||
|
* it was the only thing holding the picker together while the two real scopes
|
||||||
|
* went unused.
|
||||||
|
*/
|
||||||
|
export interface RiderQuery {
|
||||||
|
/** The merchant's own riders — hired by them, working their branches. */
|
||||||
|
tenantid?: number;
|
||||||
|
/** A delivery partner's riders. One partner supplies many merchants. */
|
||||||
|
partnerid?: number;
|
||||||
|
/**
|
||||||
|
* The delivery region — a CITY, and the fallback for neither of the above.
|
||||||
|
*
|
||||||
|
* Kept because a caller with no merchant in hand still has to ask something,
|
||||||
|
* not because it is the right scope for an assign picker.
|
||||||
|
*/
|
||||||
|
applocationid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deliveriesApi = {
|
||||||
|
/**
|
||||||
|
* Riders on duty right now, for one OWNER.
|
||||||
|
*
|
||||||
|
* "On duty" is the backend's word, not a filter added here: the query wants
|
||||||
|
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
|
||||||
|
* `logstatus = 0`. So this list empties overnight and refills as riders clock
|
||||||
|
* on, and an empty answer means nobody has started their shift — not that
|
||||||
|
* the shop has no riders. The picker has to say which.
|
||||||
|
*
|
||||||
|
* ── Why this is scoped and used to not be ─────────────────────────────────
|
||||||
|
*
|
||||||
|
* It sent `applocationid` alone, which asks "who is on duty in this city" —
|
||||||
|
* so a merchant's assign picker listed every on-duty rider in Coimbatore,
|
||||||
|
* including other merchants' own riders and every partner's. Nobody hit it
|
||||||
|
* because there is one rider on the platform. `getriders` scopes by
|
||||||
|
* applocation, partner or tenant, in that order, so the caller names which
|
||||||
|
* fleet it means and the region is only a fallback for neither.
|
||||||
|
*/
|
||||||
|
riders: (query: RiderQuery) =>
|
||||||
|
api.list<RiderInfo>(`${WEB}/partners/getriders`, {
|
||||||
|
...(query.tenantid ? { tenantid: query.tenantid } : {}),
|
||||||
|
...(query.partnerid ? { partnerid: query.partnerid } : {}),
|
||||||
|
...(query.tenantid || query.partnerid ? {} : { applocationid: query.applocationid }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand orders to a rider.
|
||||||
|
*
|
||||||
|
* An array, always, because that is what the endpoint takes and because one
|
||||||
|
* call is one transaction: each row inserts a `deliveries` row, copies it to
|
||||||
|
* `deliveryqueues` for the rider's app, and moves the parent order's status.
|
||||||
|
* Verified with three orders in a single call — three deliveries, three
|
||||||
|
* queue rows, nothing duplicated.
|
||||||
|
*
|
||||||
|
* The response carries no ids, only a message, so callers refetch rather
|
||||||
|
* than patching a row in place.
|
||||||
|
*/
|
||||||
|
assign: (rows: DeliveryDraft[]) =>
|
||||||
|
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell a rider they have work.
|
||||||
|
*
|
||||||
|
* Deliberately NOT chained into `assign` — the deliveries are committed by
|
||||||
|
* the time this runs, so a failed push must not read as a failed assignment.
|
||||||
|
* Callers fire it afterwards and report the two outcomes separately: a rider
|
||||||
|
* who was never told has work sitting unseen, which the operator needs to
|
||||||
|
* know without being told the assignment failed.
|
||||||
|
*
|
||||||
|
* The backend holds the Firebase credentials; this only relays.
|
||||||
|
*/
|
||||||
|
notify: (token: string, body: string) => {
|
||||||
|
// Checked here rather than at the server: posting an empty token returns
|
||||||
|
// FCM's "exactly one of token, topic or condition must be specified",
|
||||||
|
// which reads as a server fault rather than as a rider who has never
|
||||||
|
// opened the app. Verified against the live endpoint.
|
||||||
|
if (!token.trim()) throw new RiderNotReachableError();
|
||||||
|
return api.post<unknown>(`${WEB}/utils/notifyuser`, {
|
||||||
|
token: token.trim(),
|
||||||
|
notification: { title: 'NearleXpress', body },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a delivery along its ladder, or hand it to a different rider.
|
||||||
|
*
|
||||||
|
* `deliveryid` is the only field the backend insists on — it finds the row
|
||||||
|
* with it and derives the parent order from that rather than trusting the
|
||||||
|
* caller's `orderheaderid`, which is why a partial payload is safe here.
|
||||||
|
*
|
||||||
|
* Note what the backend does NOT do: `picked` updates the delivery and stops
|
||||||
|
* there, leaving the order at its previous status. Only pending, delivered
|
||||||
|
* and cancelled are mirrored onto the order.
|
||||||
|
*/
|
||||||
|
update: (body: UpdateDelivery) =>
|
||||||
|
api.put<unknown>(`${WEB}/deliveries/updatedelivery`, body),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delivery lifecycle, lowercase, as `deliveries.orderstatus` stores it.
|
||||||
|
*
|
||||||
|
* `skipped` is real and reachable — the rider got there and nobody was in —
|
||||||
|
* but it is written by the rider's app, not from here, so it is not offered.
|
||||||
|
*/
|
||||||
|
export const DELIVERY_STEPS = ['pending', 'accepted', 'arrived', 'picked', 'active', 'delivered'] as const;
|
||||||
|
export type DeliveryStep = (typeof DELIVERY_STEPS)[number];
|
||||||
|
|
||||||
|
export interface UpdateDelivery {
|
||||||
|
deliveryid: number;
|
||||||
|
orderstatus: string;
|
||||||
|
/** Sent when known so the backend does not have to look it up. */
|
||||||
|
orderheaderid?: number;
|
||||||
|
/** Set to move the job to a different rider. */
|
||||||
|
userid?: number;
|
||||||
|
assigntime?: string;
|
||||||
|
starttime?: string;
|
||||||
|
arrivaltime?: string;
|
||||||
|
pickuptime?: string;
|
||||||
|
deliverytime?: string;
|
||||||
|
canceltime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Riders as people, not as a fleet ────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rider being hired.
|
||||||
|
*
|
||||||
|
* Flat, though it lands in three tables — `app_users` for the person,
|
||||||
|
* `ridersettings` for the vehicle and licence, `app_userpools` for their place
|
||||||
|
* in the availability pool. The caller should not have to know the table layout
|
||||||
|
* to hire somebody, and the backend writes all three in one transaction.
|
||||||
|
*
|
||||||
|
* `tenantid` is NOT here. It goes on the query string and the backend takes it
|
||||||
|
* from there, so a payload cannot put a rider on another merchant's books.
|
||||||
|
*/
|
||||||
|
export interface NewRider {
|
||||||
|
userid?: number;
|
||||||
|
firstname: string;
|
||||||
|
lastname?: string;
|
||||||
|
contactno: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
|
||||||
|
applocationid: number;
|
||||||
|
/**
|
||||||
|
* Whose rider this is — one of these, never both.
|
||||||
|
*
|
||||||
|
* `tenantid` is a merchant's own rider; `partnerid` is a delivery partner's,
|
||||||
|
* who serves several merchants and sits under no single one. The server
|
||||||
|
* refuses neither and refuses both, so the two can never be confused
|
||||||
|
* downstream in a directory or an assign picker.
|
||||||
|
*/
|
||||||
|
tenantid?: number;
|
||||||
|
partnerid?: number;
|
||||||
|
/** The branch an OWN rider works out of. Meaningless for a partner's. */
|
||||||
|
locationid?: number;
|
||||||
|
shiftid: number;
|
||||||
|
identificationno?: string;
|
||||||
|
vehiclename?: string;
|
||||||
|
vehicleno?: string;
|
||||||
|
licenseno?: string;
|
||||||
|
registrationno?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rider in the directory.
|
||||||
|
*
|
||||||
|
* `isonduty` is the field to read for "are they working right now" — `onduty`
|
||||||
|
* is the availability flag, which is 1 for anyone who may be given work at all.
|
||||||
|
* A rider hired this morning has `onduty: 1` and `isonduty: false` until they
|
||||||
|
* open the app and start a shift.
|
||||||
|
*/
|
||||||
|
export interface RiderRosterRow {
|
||||||
|
userid: number;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
fullname?: string;
|
||||||
|
contactno?: string;
|
||||||
|
email?: string;
|
||||||
|
tenantid?: number;
|
||||||
|
/** The branch an own rider works out of, and its name. */
|
||||||
|
locationid?: number;
|
||||||
|
locationname?: string;
|
||||||
|
applocationid?: number;
|
||||||
|
applocation?: string;
|
||||||
|
partnerid?: number;
|
||||||
|
partnername?: string;
|
||||||
|
shiftid?: number;
|
||||||
|
shiftname?: string;
|
||||||
|
identificationno?: string;
|
||||||
|
vehiclename?: string;
|
||||||
|
vehicleno?: string;
|
||||||
|
licenseno?: string;
|
||||||
|
registrationno?: string;
|
||||||
|
/** May be given work at all. */
|
||||||
|
onduty?: number;
|
||||||
|
lastlogdate?: string;
|
||||||
|
/** On shift right now — a log dated today. */
|
||||||
|
isonduty?: boolean;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Partner {
|
||||||
|
partnerid: number;
|
||||||
|
partnername?: string;
|
||||||
|
companyname?: string;
|
||||||
|
applocationid?: number;
|
||||||
|
primarycontact?: string;
|
||||||
|
primaryemail?: string;
|
||||||
|
contactno?: string;
|
||||||
|
registrationno?: string;
|
||||||
|
address?: string;
|
||||||
|
suburb?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One region a partner covers — a row of `partnerlocations`. */
|
||||||
|
export interface PartnerLocation {
|
||||||
|
partnerlocationid: number;
|
||||||
|
partnerid: number;
|
||||||
|
applocationid: number;
|
||||||
|
applocation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivery region. `applocationid=0` asks for all of them. */
|
||||||
|
export interface AppLocation {
|
||||||
|
applocationid: number;
|
||||||
|
locationname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the console collects to onboard a delivery partner. */
|
||||||
|
export interface NewPartner {
|
||||||
|
partnerid?: number;
|
||||||
|
partnername: string;
|
||||||
|
companyname?: string;
|
||||||
|
registrationno?: string;
|
||||||
|
primarycontact: string;
|
||||||
|
primaryemail?: string;
|
||||||
|
contactno?: string;
|
||||||
|
address?: string;
|
||||||
|
suburb?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postcode?: number;
|
||||||
|
status?: string;
|
||||||
|
/** The district they work — one, never a set. */
|
||||||
|
applocationid: number;
|
||||||
|
/**
|
||||||
|
* The district by NAME, for one Nearle has not opened yet.
|
||||||
|
*
|
||||||
|
* Sending it opens the district: the server writes the `app_location` and
|
||||||
|
* `app_locationconfig` rows every rider query joins through. Ignored when
|
||||||
|
* `applocationid` is set, which is the ordinary case.
|
||||||
|
*/
|
||||||
|
district?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiderShift {
|
||||||
|
shiftid: number;
|
||||||
|
shiftname?: string;
|
||||||
|
starttime?: string;
|
||||||
|
endtime?: string;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* NOT `getriders`, which requires a clock-in dated today. That one answers
|
||||||
|
* "who can take this delivery now" and is right for the assign picker; used
|
||||||
|
* as a staff list it hides the rider you just created, which reads as a
|
||||||
|
* failed save.
|
||||||
|
*/
|
||||||
|
roster: (tenantid: number) =>
|
||||||
|
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hire one for a MERCHANT. `tenantid` travels as a param — the backend takes
|
||||||
|
* the scope from there rather than trusting the body, so a store admin cannot
|
||||||
|
* put a rider on another merchant's books by editing a payload.
|
||||||
|
*/
|
||||||
|
create: (tenantid: number, rider: NewRider) =>
|
||||||
|
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hire one for a delivery PARTNER.
|
||||||
|
*
|
||||||
|
* Same endpoint, same rider — what differs is who they ride for. A partner
|
||||||
|
* has no console of its own, so their riders are added by the platform.
|
||||||
|
*/
|
||||||
|
createForPartner: (partnerid: number, rider: NewRider) =>
|
||||||
|
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { partnerid }),
|
||||||
|
|
||||||
|
/** A partner's riders, for the platform's directory. */
|
||||||
|
partnerRoster: (partnerid: number) =>
|
||||||
|
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { partnerid }),
|
||||||
|
|
||||||
|
update: (rider: NewRider & { userid: number }) =>
|
||||||
|
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
|
||||||
|
|
||||||
|
/** Shifts to choose from. Scoped by region, and the param is required. */
|
||||||
|
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 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivery partners — the companies that supply riders.
|
||||||
|
*
|
||||||
|
* A partner is onboarded by the platform and then ASSIGNED to merchants; a
|
||||||
|
* merchant never creates one. That split is why `assign` lives on the tenant
|
||||||
|
* API and not here, and why `partnerid` is kept out of the merchant-editable
|
||||||
|
* profile allowlist on the server.
|
||||||
|
*
|
||||||
|
* One partner routinely serves many merchants: partner 44 supplies 48 of them
|
||||||
|
* and partner 60 supplies 63, measured on 2026-09-09.
|
||||||
|
*/
|
||||||
|
export const partnersApi = {
|
||||||
|
/** Every partner in a region. `applocationid` 0 is not accepted here. */
|
||||||
|
list: (applocationid: number) =>
|
||||||
|
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
|
||||||
|
|
||||||
|
/** One partner, by id. */
|
||||||
|
byId: (partnerid: number) =>
|
||||||
|
api.list<Partner>(`${WEB}/partners/getpartners`, { partnerid }),
|
||||||
|
|
||||||
|
create: (partner: NewPartner) =>
|
||||||
|
api.post<{ partnerid: number }>(`${WEB}/partners/createpartner`, partner),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a partner. Regions are REPLACED when sent and left alone when not, so
|
||||||
|
* an edit that changes only a phone number cannot empty the list.
|
||||||
|
*/
|
||||||
|
update: (partner: NewPartner & { partnerid: number }) =>
|
||||||
|
api.put<unknown>(`${WEB}/partners/updatepartner`, partner),
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
370
src/api/ingest.test.ts
Normal file
370
src/api/ingest.test.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* The review inbox, and the status that nearly slipped through as success.
|
||||||
|
*
|
||||||
|
* The fixture is the live response to an anonymous upload on 28 Aug 2026 —
|
||||||
|
* `status: "pending"`, every total zero, the file still queued.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
isAwaitingReview,
|
||||||
|
isDismissed,
|
||||||
|
isSettled,
|
||||||
|
pollDelayFor,
|
||||||
|
currentStage,
|
||||||
|
isStuckOnMissingRunner,
|
||||||
|
productsOf,
|
||||||
|
releasedRunId,
|
||||||
|
summarise,
|
||||||
|
type IngestBatch,
|
||||||
|
} from './ingest';
|
||||||
|
|
||||||
|
const held = {
|
||||||
|
batch_id: '2ee38d06b583454ea0278a7f6de2c87f',
|
||||||
|
status: 'pending',
|
||||||
|
detail: 'Waiting for review. Nothing runs until an admin starts it.',
|
||||||
|
submitted_by: 'anonymous',
|
||||||
|
files_total: 1,
|
||||||
|
files_done: 0,
|
||||||
|
files_failed: 0,
|
||||||
|
totals: {
|
||||||
|
rows_total: 0,
|
||||||
|
products_built: 0,
|
||||||
|
inserted: 0,
|
||||||
|
backfilled: 0,
|
||||||
|
skipped_existing: 0,
|
||||||
|
rejected: 0,
|
||||||
|
},
|
||||||
|
brands: [],
|
||||||
|
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const }],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
// The bug this guards: `isSettled` used to mean "not queued and not running",
|
||||||
|
// so `pending` counted as finished and the panel rendered a completed import of
|
||||||
|
// zero products for a batch that had not started.
|
||||||
|
test('a batch held for review is not treated as finished', () => {
|
||||||
|
assert.equal(isSettled(held), false, 'a held batch must not read as settled');
|
||||||
|
assert.equal(isAwaitingReview(held), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the summary says it is waiting, not that nothing imported', () => {
|
||||||
|
const line = summarise(held);
|
||||||
|
assert.match(line, /review/i);
|
||||||
|
assert.doesNotMatch(line, /0 added/, 'must not report an import that never ran');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real result is still settled', () => {
|
||||||
|
for (const status of ['done', 'partial', 'failed', 'interrupted', 'cancelled'] as const) {
|
||||||
|
assert.equal(isSettled({ ...held, status }), true, `${status} should be settled`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued and running are still in flight', () => {
|
||||||
|
for (const status of ['queued', 'running'] as const) {
|
||||||
|
assert.equal(isSettled({ ...held, status }), false, `${status} should not be settled`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// isSettled is written as a positive list precisely so a status nobody
|
||||||
|
// anticipated stalls a spinner rather than fabricating a completed import.
|
||||||
|
test('an unknown future status does not read as finished', () => {
|
||||||
|
const unknown = { ...held, status: 'quarantined' as unknown as IngestBatch['status'] };
|
||||||
|
assert.equal(isSettled(unknown), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── The drop lifecycle ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const released = {
|
||||||
|
...held,
|
||||||
|
batch_id: '9f088d949aa9',
|
||||||
|
status: 'pending' as const,
|
||||||
|
files: [{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' }],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
const dismissed = {
|
||||||
|
...held,
|
||||||
|
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
// A released drop is not still waiting — the run is one hop away, and treating
|
||||||
|
// it as held would leave the screen saying "queued for review" forever.
|
||||||
|
test('a released drop is no longer awaiting review', () => {
|
||||||
|
assert.equal(releasedRunId(released), '8dcef8a2ad94');
|
||||||
|
assert.equal(isAwaitingReview(released), false);
|
||||||
|
assert.equal(isAwaitingReview(held), true, 'an unreleased drop is still waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Declined is terminal. Polling on is waiting for something that cannot happen.
|
||||||
|
test('a dismissed drop is recognised and reported as declined', () => {
|
||||||
|
assert.equal(isDismissed(dismissed), true);
|
||||||
|
assert.equal(isDismissed(held), false);
|
||||||
|
assert.match(summarise(dismissed), /declined/i);
|
||||||
|
assert.doesNotMatch(summarise(dismissed), /0 added/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the manifest is collected across files', () => {
|
||||||
|
const done = {
|
||||||
|
...held,
|
||||||
|
status: 'done' as const,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
filename: 'a.csv',
|
||||||
|
status: 'done' as const,
|
||||||
|
result: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
image_id: 'amul_amul_butter_100g',
|
||||||
|
brand: 'amul',
|
||||||
|
product_name: 'Amul Butter 100g',
|
||||||
|
product_sku: 'ACME-BUT-100',
|
||||||
|
sku_source: 'sheet',
|
||||||
|
disposition: 'inserted' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
const products = productsOf(done);
|
||||||
|
assert.equal(products.length, 1);
|
||||||
|
// image_id is the join key; matching on name creates duplicates instead of
|
||||||
|
// updating, which is why it is asserted rather than the name.
|
||||||
|
assert.equal(products[0]?.image_id, 'amul_amul_butter_100g');
|
||||||
|
assert.equal(products[0]?.disposition, 'inserted');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── retired: the drop is spent, the answer is on the files ───────────────── */
|
||||||
|
|
||||||
|
// A drop released into a run reads `retired`, and the run is elsewhere. Calling
|
||||||
|
// it finished would report an import that is running right now as a completed
|
||||||
|
// import of zero products.
|
||||||
|
test('a retired drop that was released is not finished', () => {
|
||||||
|
const retired = {
|
||||||
|
...held,
|
||||||
|
status: 'retired' as const,
|
||||||
|
files: [
|
||||||
|
{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' },
|
||||||
|
],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
assert.equal(isSettled(retired), false, 'the run still has to be followed');
|
||||||
|
assert.equal(releasedRunId(retired), '8dcef8a2ad94');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retired with nothing to follow is genuinely over — otherwise the panel spins
|
||||||
|
// on a drop that no longer exists.
|
||||||
|
test('a retired drop with nowhere to follow is finished', () => {
|
||||||
|
const retired = {
|
||||||
|
...held,
|
||||||
|
status: 'retired' as const,
|
||||||
|
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
assert.equal(isSettled(retired), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Cross-drop contamination ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// An admin can assemble one run from several drops, so a run's manifest can
|
||||||
|
// carry other senders' products. Applying our sheet's price and opening stock
|
||||||
|
// to those would stock someone else's goods into our merchant's branch.
|
||||||
|
test('only our own file contributes products', () => {
|
||||||
|
const run = {
|
||||||
|
...held,
|
||||||
|
status: 'done' as const,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
filename: 'ours.csv',
|
||||||
|
status: 'done' as const,
|
||||||
|
result: {
|
||||||
|
products: [
|
||||||
|
{ image_id: 'amul_a', brand: 'amul', product_name: 'Ours', disposition: 'inserted' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
filename: 'someone-elses.csv',
|
||||||
|
status: 'done' as const,
|
||||||
|
result: {
|
||||||
|
products: [
|
||||||
|
{ image_id: 'amul_b', brand: 'amul', product_name: 'Theirs', disposition: 'inserted' as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
|
||||||
|
const mine = productsOf(run, ['ours.csv']);
|
||||||
|
assert.equal(mine.length, 1);
|
||||||
|
assert.equal(mine[0]?.product_name, 'Ours');
|
||||||
|
|
||||||
|
// Unfiltered still returns everything — the filter is the caller's decision,
|
||||||
|
// and every caller that prices products must make it.
|
||||||
|
assert.equal(productsOf(run).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The stage timeline, and the runner that silently isn't there.
|
||||||
|
|
||||||
|
Both arrived with the ingest team's 31 Aug documentation update. The timeline is
|
||||||
|
what lets the console draw the real eleven stages instead of a file-count bar;
|
||||||
|
the runner is a trap, and the more important of the two.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const runningFile = {
|
||||||
|
index: 0,
|
||||||
|
filename: 'catalog.csv',
|
||||||
|
status: 'running' as const,
|
||||||
|
stage_index: 6,
|
||||||
|
stage_name: 'Image Search & Contamination Filtering',
|
||||||
|
total_stages: 11,
|
||||||
|
rows_done: 120,
|
||||||
|
rows_total: 400,
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
name: 'Brand Resolution & FSSAI Licence Mapping',
|
||||||
|
rows_done: 400,
|
||||||
|
rows_total: 400,
|
||||||
|
started_at: 1756612800.1,
|
||||||
|
finished_at: 1756612801.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 6,
|
||||||
|
name: 'Image Search & Contamination Filtering',
|
||||||
|
rows_done: 120,
|
||||||
|
rows_total: 400,
|
||||||
|
started_at: 1756612809.7,
|
||||||
|
finished_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// `finished_at: null` is the marker, not the last array entry and not
|
||||||
|
// `stage_index`. Reading the position any other way breaks the moment a stage
|
||||||
|
// completes out of order or the array carries a trailing finished entry.
|
||||||
|
test('the running stage is the one with no finish time', () => {
|
||||||
|
const stage = currentStage(runningFile);
|
||||||
|
assert.equal(stage?.index, 6);
|
||||||
|
assert.equal(stage?.rows_done, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A finished file keeps its history, which is the whole reason the timeline
|
||||||
|
// exists — the scalars only ever describe the present moment, and for a
|
||||||
|
// finished file that moment is over.
|
||||||
|
test('a finished file still reports its last stage', () => {
|
||||||
|
const done = {
|
||||||
|
...runningFile,
|
||||||
|
status: 'done' as const,
|
||||||
|
stages: runningFile.stages.map((s) => ({ ...s, finished_at: s.finished_at ?? 1756612900.0 })),
|
||||||
|
};
|
||||||
|
assert.equal(currentStage(done)?.index, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A service build that predates the timeline still has to render. The scalars
|
||||||
|
// are the fallback, not the source of truth.
|
||||||
|
test('a response without a timeline falls back to the scalars', () => {
|
||||||
|
const { stages: _stages, ...noTimeline } = runningFile;
|
||||||
|
const stage = currentStage(noTimeline);
|
||||||
|
assert.equal(stage?.index, 6);
|
||||||
|
assert.equal(stage?.name, 'Image Search & Contamination Filtering');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file that has not started reports no stage at all', () => {
|
||||||
|
assert.equal(currentStage({ index: 0, filename: 'a.csv', status: 'queued' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
`runner: "dagster"` never runs in production — Dagster is a development tool,
|
||||||
|
absent from the deployed image — so the batch waits for a worker that will never
|
||||||
|
claim it. Every visible signal is identical to a batch merely waiting its turn,
|
||||||
|
which is exactly why it has to be named rather than rendered as progress.
|
||||||
|
*/
|
||||||
|
test('a batch staged for the absent orchestrator is called out', () => {
|
||||||
|
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'dagster' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the in-process runner is not a stall', () => {
|
||||||
|
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'inprocess' }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A batch that reached `running` plainly found an executor, whatever it was
|
||||||
|
// staged for. Warning then would contradict the progress on screen.
|
||||||
|
test('a batch already running is not stuck, whatever it was staged for', () => {
|
||||||
|
assert.equal(isStuckOnMissingRunner({ ...held, status: 'running', runner: 'dagster' }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
A drop is not a run, and the difference is easy to lose.
|
||||||
|
|
||||||
|
`released_to` lives on the DROP's files. Once an admin releases it, following
|
||||||
|
that pointer lands on the run — and the run carries no `released_to` of its own,
|
||||||
|
because nothing released it. So a caller who resolves first and asks for the run
|
||||||
|
id second gets null, and the only pointer from the id they hold to the id with
|
||||||
|
the results is never recorded.
|
||||||
|
|
||||||
|
This cost a real bug in both directions: the Uploads page never saved a run id,
|
||||||
|
and the import panel keyed the shelving write on `batch.batch_id` — which by
|
||||||
|
then was the run — updating a receipt row that does not exist, silently.
|
||||||
|
*/
|
||||||
|
test('the run id is on the drop, and gone from the run it points to', () => {
|
||||||
|
const drop = {
|
||||||
|
...held,
|
||||||
|
batch_id: 'drop-1',
|
||||||
|
status: 'retired' as const,
|
||||||
|
files: [
|
||||||
|
{ index: 0, filename: 'catalog.csv', status: 'released' as const, released_to: 'run-1' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(releasedRunId(drop), 'run-1');
|
||||||
|
|
||||||
|
// The same question asked of the run answers null. Read the drop first.
|
||||||
|
const run = {
|
||||||
|
...held,
|
||||||
|
batch_id: 'run-1',
|
||||||
|
status: 'done' as const,
|
||||||
|
files: [{ index: 0, filename: 'catalog.csv', status: 'done' as const }],
|
||||||
|
};
|
||||||
|
assert.equal(releasedRunId(run), null);
|
||||||
|
|
||||||
|
// And the two ids differ, which is exactly why a receipt keyed on the drop
|
||||||
|
// cannot be written using the run's.
|
||||||
|
assert.notEqual(drop.batch_id, run.batch_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── How often to look, and when to stop looking ──────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
The console used to stop polling the moment a drop went to review, on the
|
||||||
|
reasoning that waiting for an admin is not progress. It is not — but the release
|
||||||
|
IS, and stopping there meant the panel said "waiting for review" until somebody
|
||||||
|
reloaded the page. A step-by-step panel that only advances on reload is the
|
||||||
|
thing the panel exists to replace.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('a review hold is polled slowly, not abandoned', () => {
|
||||||
|
assert.equal(isAwaitingReview(held), true);
|
||||||
|
assert.equal(pollDelayFor(held), 15000, 'a hold can last hours; 2s would be 1,800 reads an hour');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a running batch is polled at a pace a person can watch', () => {
|
||||||
|
const running = { ...held, status: 'running' } satisfies IngestBatch;
|
||||||
|
assert.equal(isAwaitingReview(running), false);
|
||||||
|
assert.equal(pollDelayFor(running), 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A released drop is no longer waiting on anybody, so it goes back to the fast
|
||||||
|
// cadence even though its own status still reads "pending".
|
||||||
|
test('a released drop is followed at the running pace', () => {
|
||||||
|
const released = {
|
||||||
|
...held,
|
||||||
|
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const, released_to: 'run-77' }],
|
||||||
|
} satisfies IngestBatch;
|
||||||
|
assert.equal(releasedRunId(released), 'run-77');
|
||||||
|
assert.equal(isAwaitingReview(released), false);
|
||||||
|
assert.equal(pollDelayFor(released), 2000);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import type {
|
|||||||
DeliveryRow,
|
DeliveryRow,
|
||||||
DeliverySummary,
|
DeliverySummary,
|
||||||
LocationOrderSummary,
|
LocationOrderSummary,
|
||||||
|
OrderItem,
|
||||||
OrderRow,
|
OrderRow,
|
||||||
OrderSummary,
|
OrderSummary,
|
||||||
PosLocationHealth,
|
PosLocationHealth,
|
||||||
@@ -29,6 +30,18 @@ export interface OrderQuery extends DateRange {
|
|||||||
tenantid: number;
|
tenantid: number;
|
||||||
/** Omit for every branch of the tenant. */
|
/** Omit for every branch of the tenant. */
|
||||||
locationid?: number;
|
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;
|
status?: string;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
@@ -51,7 +64,7 @@ export const insightsApi = {
|
|||||||
*/
|
*/
|
||||||
orders: (query: OrderQuery) =>
|
orders: (query: OrderQuery) =>
|
||||||
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
|
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
|
||||||
tenantid: query.tenantid,
|
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
keyword: query.keyword,
|
keyword: query.keyword,
|
||||||
@@ -61,6 +74,41 @@ export const insightsApi = {
|
|||||||
pagesize: query.pagesize ?? 50,
|
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.
|
* The delivery jobs.
|
||||||
*
|
*
|
||||||
@@ -75,7 +123,7 @@ export const insightsApi = {
|
|||||||
*/
|
*/
|
||||||
deliveries: (query: OrderQuery) =>
|
deliveries: (query: OrderQuery) =>
|
||||||
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
|
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
|
||||||
tenantid: query.tenantid,
|
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
keyword: query.keyword,
|
keyword: query.keyword,
|
||||||
@@ -95,8 +143,52 @@ export const insightsApi = {
|
|||||||
revenueSummary: (tenantid: number, range: DateRange = {}) =>
|
revenueSummary: (tenantid: number, range: DateRange = {}) =>
|
||||||
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
||||||
|
|
||||||
timeSeries: (tenantid: number, range: DateRange = {}) =>
|
/**
|
||||||
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, { tenantid, ...range }),
|
* `granularity` is REQUIRED and was never sent, so this call answered 400
|
||||||
|
* every single time: "granularity query parameter is required (day, month,
|
||||||
|
* year)". Nothing renders it yet, which is the only reason it went unnoticed
|
||||||
|
* — the first screen to use it would have shown an error instead of a chart.
|
||||||
|
*
|
||||||
|
* Defaulted rather than made a required argument: a day-by-day series is what
|
||||||
|
* every caller of a dated range wants, and a parameter with one sensible
|
||||||
|
* answer should not be every caller's problem.
|
||||||
|
*/
|
||||||
|
timeSeries: (
|
||||||
|
tenantid: number,
|
||||||
|
range: DateRange = {},
|
||||||
|
granularity: 'day' | 'month' | 'year' = 'day',
|
||||||
|
) =>
|
||||||
|
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, {
|
||||||
|
tenantid,
|
||||||
|
granularity,
|
||||||
|
...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 = {}) =>
|
deliverySummary: (tenantid: number, range: DateRange = {}) =>
|
||||||
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
|
||||||
|
|||||||
166
src/api/nutrition.ts
Normal file
166
src/api/nutrition.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Health scores and nutrition, from the catalogue-intelligence service.
|
||||||
|
*
|
||||||
|
* A SEPARATE HOST from Fiesta — `mcp.nearle.ai.in`, the same service that
|
||||||
|
* scrapes the global catalogue — so it does not go through `client.ts`, which
|
||||||
|
* exists to talk to one backend. It is read-only and unauthenticated, like the
|
||||||
|
* catalogue reads beside it.
|
||||||
|
*
|
||||||
|
* ── The join key ────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `image_id`, not `catalogueid`. That is the same stable key the catalogue
|
||||||
|
* import already uses, and for the same reason: `catalogueid` is renumbered on
|
||||||
|
* every re-scrape, so a link made through it goes stale silently. A product
|
||||||
|
* carries its `imageid` from the import, and that is what resolves here.
|
||||||
|
*
|
||||||
|
* ── Two things measured against the live service, 4 Sep 2026 ────────────────
|
||||||
|
*
|
||||||
|
* - `include_unknown=true` is REQUIRED or the list returns nothing. With it,
|
||||||
|
* 252 items; without it, zero — including products whose `data_status` is
|
||||||
|
* "verified" and whose score is a real number. The flag reads like it should
|
||||||
|
* only add unscored rows; in practice its absence removes everything.
|
||||||
|
*
|
||||||
|
* - Scoring covers ten brands (Nestle, Amul, Coca-Cola, Cadbury and six
|
||||||
|
* smaller ones). None of the brands our merchants actually stock are among
|
||||||
|
* them yet, so today this renders on no products at all. The wiring is
|
||||||
|
* correct; the data has to catch up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NUTRITION_BASE = 'https://mcp.nearle.ai.in/api';
|
||||||
|
|
||||||
|
/** How confident the service is that it matched the right source record. */
|
||||||
|
export const LOW_CONFIDENCE = 0.7;
|
||||||
|
|
||||||
|
export interface NutritionScore {
|
||||||
|
brand?: string;
|
||||||
|
image_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
/** 0–100. `null` when the product is known but has not been scored. */
|
||||||
|
health_score?: number | null;
|
||||||
|
nutrition_score?: number | null;
|
||||||
|
health_band?: string | null;
|
||||||
|
scoring_version?: string | null;
|
||||||
|
|
||||||
|
/** Sentences, already written for a person. Rendered as given. */
|
||||||
|
positive_insights?: string[];
|
||||||
|
nutritional_cautions?: string[];
|
||||||
|
ai_summary?: string | null;
|
||||||
|
|
||||||
|
diet_tags?: string[];
|
||||||
|
allergens?: string[];
|
||||||
|
|
||||||
|
/** "verified" when the source record was confirmed. */
|
||||||
|
data_status?: string | null;
|
||||||
|
data_source?: string | null;
|
||||||
|
source_url?: string | null;
|
||||||
|
/**
|
||||||
|
* 0–1. The 5 Star record scores 0.577 — a moderate match, not a certainty.
|
||||||
|
*
|
||||||
|
* Surfaced rather than hidden. A nutrition panel presented as fact when the
|
||||||
|
* underlying match is a guess is worse than no panel, and that goes double
|
||||||
|
* for the allergen list.
|
||||||
|
*/
|
||||||
|
match_confidence?: number | null;
|
||||||
|
|
||||||
|
serving_size_g?: number | null;
|
||||||
|
serving_size_label?: string | null;
|
||||||
|
calories_kcal?: number | null;
|
||||||
|
protein_g?: number | null;
|
||||||
|
carbohydrates_g?: number | null;
|
||||||
|
total_sugar_g?: number | null;
|
||||||
|
added_sugar_g?: number | null;
|
||||||
|
dietary_fiber_g?: number | null;
|
||||||
|
total_fat_g?: number | null;
|
||||||
|
saturated_fat_g?: number | null;
|
||||||
|
sodium_mg?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read<T>(path: string): Promise<T | null> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${NUTRITION_BASE}${path}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A nutrition panel is an enhancement on a product page. If the service is
|
||||||
|
// unreachable the page still has to render, so this reports "nothing"
|
||||||
|
// rather than throwing into the drawer.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) return null;
|
||||||
|
try {
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Their brand spelling, resolved from ours.
|
||||||
|
*
|
||||||
|
* The two catalogues agree on every brand and disagree on how to write it:
|
||||||
|
*
|
||||||
|
* ours theirs
|
||||||
|
* cadbury → Cadbury
|
||||||
|
* coca_cola → Coca-Cola underscore becomes a HYPHEN
|
||||||
|
* brooke_bond → Brooke Bond underscore becomes a SPACE
|
||||||
|
* 24_mantra → 24 Mantra
|
||||||
|
*
|
||||||
|
* Which separator an underscore becomes cannot be derived — it is a hyphen for
|
||||||
|
* Coca-Cola and Colgate-Palmolive and a space for everything else. So the list
|
||||||
|
* is fetched and matched on a normalised form rather than guessed at.
|
||||||
|
*
|
||||||
|
* This is not cosmetic. `GET /nutrition/cadbury/...` returns
|
||||||
|
* `health_score: null` — a well-formed answer meaning "no score", not an error
|
||||||
|
* — so getting the case wrong looks exactly like a product nobody has scored,
|
||||||
|
* on every product, forever.
|
||||||
|
*
|
||||||
|
* Cached for the process: a brand list changes when the scraper learns a new
|
||||||
|
* brand, which is not during a session.
|
||||||
|
*/
|
||||||
|
let brandsPromise: Promise<string[]> | null = null;
|
||||||
|
|
||||||
|
function normalise(brand: string): string {
|
||||||
|
return brand.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBrand(raw: string): Promise<string> {
|
||||||
|
const wanted = normalise(raw);
|
||||||
|
if (!wanted) return raw;
|
||||||
|
|
||||||
|
brandsPromise ??= read<{ brands?: string[] }>('/brands').then((r) => r?.brands ?? []);
|
||||||
|
const brands = await brandsPromise;
|
||||||
|
|
||||||
|
// Their exact spelling if we know it; ours unchanged if we do not, so a brand
|
||||||
|
// they have not listed still gets a real attempt rather than being dropped.
|
||||||
|
return brands.find((candidate) => normalise(candidate) === wanted) ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nutritionApi = {
|
||||||
|
/**
|
||||||
|
* One product's score and nutrition.
|
||||||
|
*
|
||||||
|
* Returns null when the product is unknown to the service, and a record with
|
||||||
|
* `health_score: null` when it is known but unscored — two different answers
|
||||||
|
* that must not be collapsed, because the second means "coming soon" and the
|
||||||
|
* first means "this product was never in the catalogue".
|
||||||
|
*/
|
||||||
|
forProduct: async (brand: string, imageId: string) => {
|
||||||
|
// Resolved first: our catalogue spells brands in snake_case and theirs does
|
||||||
|
// not, and the mismatch reads as "unscored" rather than as an error.
|
||||||
|
const resolved = await resolveBrand(brand);
|
||||||
|
return read<NutritionScore>(
|
||||||
|
`/nutrition/${encodeURIComponent(resolved)}/${encodeURIComponent(imageId)}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Exposed for the check script, which asserts the two vocabularies still line up. */
|
||||||
|
export const __resolveBrand = resolveBrand;
|
||||||
|
|
||||||
|
/** True when the service knows the product but has not scored it yet. */
|
||||||
|
export function isUnscored(score: NutritionScore | null): boolean {
|
||||||
|
return score !== null && (score.health_score === null || score.health_score === undefined);
|
||||||
|
}
|
||||||
244
src/api/optimiser.ts
Normal file
244
src/api/optimiser.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
|
||||||
|
import type { OrderRow } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route optimiser.
|
||||||
|
*
|
||||||
|
* A SEPARATE SERVICE from Fiesta — `routes.workolik.com`, "Route Optimization
|
||||||
|
* API v2.0.0" — so it does not go through `client.ts`, which exists to talk to
|
||||||
|
* one backend. Road routing is real (a Valhalla backend, not straight lines)
|
||||||
|
* and the assignment model is trained: 3,627 records, tuned to 20 orders per
|
||||||
|
* rider and an ideal load of 4.
|
||||||
|
*
|
||||||
|
* ── What it is and is not ───────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `optimization/createdeliveries` is NOT a create, despite the name it shares
|
||||||
|
* with Fiesta's. It is a pure function: send an array of orders, get the same
|
||||||
|
* array back reordered nearest-neighbour with `step`, `previouskms`,
|
||||||
|
* `cumulativekms`, `actualkms` and `eta` added. Its own docs say forwarding is
|
||||||
|
* paused, and the verified behaviour matches — it writes nothing anywhere.
|
||||||
|
*
|
||||||
|
* So the sequence is ours to commit: we take its answer and post it to Fiesta's
|
||||||
|
* `deliveries/createdeliveries` ourselves. That is also what the xpress console
|
||||||
|
* does, which is the only reason its two identically-named endpoints do not
|
||||||
|
* collide.
|
||||||
|
*
|
||||||
|
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
|
||||||
|
*
|
||||||
|
* 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. */
|
||||||
|
step?: number;
|
||||||
|
/** Kilometres from the previous stop. */
|
||||||
|
previouskms?: number;
|
||||||
|
/** Running total for the round. */
|
||||||
|
cumulativekms?: number;
|
||||||
|
/**
|
||||||
|
* Direct pickup-to-delivery distance, as a string.
|
||||||
|
*
|
||||||
|
* The service returns these as strings ("1.23"), which is also how Fiesta's
|
||||||
|
* `deliveries.kms` / `actualkms` columns are typed — so they carry across
|
||||||
|
* unconverted. Those columns are exactly the ones found holding the literal
|
||||||
|
* text "null" in production, which broke the rider summary; a sequence run is
|
||||||
|
* what should be filling them with real numbers.
|
||||||
|
*/
|
||||||
|
actualkms?: string;
|
||||||
|
kms?: string;
|
||||||
|
/** Minutes for this leg, and cumulative. Strings, as sent. */
|
||||||
|
eta?: string;
|
||||||
|
cumulative_eta?: string;
|
||||||
|
ordertype?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One rider's leg of a plan, in the shape reconcile expects back. */
|
||||||
|
export interface PlannedRider {
|
||||||
|
rider_id: string | number;
|
||||||
|
rider_name?: string;
|
||||||
|
orders: SequencedStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OptimiserError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OptimiserError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${OPTIMISER_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A separate host means a separate failure mode: the optimiser can be down
|
||||||
|
// while Fiesta is fine. Said plainly so nobody debugs the wrong service.
|
||||||
|
throw new OptimiserError('Could not reach the route optimiser');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: { code?: number; details?: T; message?: string; error?: { message?: string } };
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new OptimiserError(`The optimiser sent a malformed reply (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new OptimiserError(payload?.error?.message || payload?.message || `Optimiser refused the request (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// It answers `{code, details}` on the sequencing route and a bare object
|
||||||
|
// elsewhere, so both shapes are unwrapped here rather than at each call site.
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Send them in any order; they come back sorted with a step number and the
|
||||||
|
* distance and time between each. Verified against the live service with our
|
||||||
|
* own field names — it reads `pickuplat`/`pickuplong` and
|
||||||
|
* `deliverylat`/`deliverylong`, which order rows already carry.
|
||||||
|
*/
|
||||||
|
sequence: (orders: OrderRow[]) =>
|
||||||
|
post<SequencedStop[]>('/optimization/createdeliveries', orders),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair step numbers after somebody moved a stop by hand.
|
||||||
|
*
|
||||||
|
* Moving one order between riders breaks two rounds at once: the rider who
|
||||||
|
* lost it has a hole in its sequence (1,2,3,5,6) and the one who gained it
|
||||||
|
* has a step that collides or is missing. This fixes both.
|
||||||
|
*
|
||||||
|
* It MUST run before the plan is committed. The team who built the page this
|
||||||
|
* came from call skipping it "the single biggest production bug to avoid in
|
||||||
|
* this area" — it corrupts route sequences in the database, and nothing at
|
||||||
|
* the point of the write can tell.
|
||||||
|
*
|
||||||
|
* Send only the riders that were edited; the response carries those riders
|
||||||
|
* back and the rest of the plan is left alone.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* account ends up holding a role that matches nothing.
|
* account ends up holding a role that matches nothing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api, MOB, WEB } from './client';
|
import { api, WEB } from './client';
|
||||||
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
|
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
|
||||||
|
|
||||||
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
||||||
@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
|
|||||||
status?: string;
|
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 {
|
export interface UpdateStaffRequest {
|
||||||
userid: number;
|
userid: number;
|
||||||
firstname?: string;
|
firstname?: string;
|
||||||
@@ -57,17 +75,70 @@ export const staffApi = {
|
|||||||
* back-office roles and most accounts carry an id absent from it, so any
|
* back-office roles and most accounts carry an id absent from it, so any
|
||||||
* mapping written client-side is wrong."
|
* mapping written client-side is wrong."
|
||||||
*
|
*
|
||||||
* On the MOB prefix, and that is not a choice: `getstaffs` is registered on
|
* On WEB now. It used to be on MOB because `getstaffs` was registered under
|
||||||
* `/v1/mob/tenants` only (`tenantroutes.go:35`) and has no `/web` twin, so the
|
* `/v1/mob/tenants` alone and had no `/web` twin — back-office staff were
|
||||||
* path this used to call did not exist. The old console avoided the question
|
* reachable only through the customer app's door, which is a large part of
|
||||||
* by using `users/getallusers`, whose SQL selects no `rolename` at all — it
|
* why this console never had a people screen. The twin now exists; the MOB
|
||||||
* had to map role ids client-side, which is the thing the backend warns
|
* registration is left in place in case something else calls it.
|
||||||
* against above.
|
*
|
||||||
|
* Returns people with NO branch as well as people with one. That is the whole
|
||||||
|
* point: `locationid` 0 means hired and not yet placed, and the list is
|
||||||
|
* ordered to put them first, because they are the rows needing an action.
|
||||||
*/
|
*/
|
||||||
list: (tenantid: number) =>
|
list: (tenantid: number) =>
|
||||||
api.list<StaffInfo>(`${MOB}/tenants/getstaffs`, { tenantid }),
|
api.list<StaffInfo>(`${WEB}/tenants/getstaffs`, { tenantid }),
|
||||||
|
|
||||||
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
/**
|
||||||
|
* Put somebody at a branch, or take them off one.
|
||||||
|
*
|
||||||
|
* `unassign` is a separate flag rather than `locationid: 0`, deliberately. A
|
||||||
|
* body that lost the field, a form that posted a blank and a client that
|
||||||
|
* dropped it all arrive as 0 — so a zero alone must never mean "take them off
|
||||||
|
* their shop". The backend refuses it too; this mirrors the rule so the
|
||||||
|
* refusal is not a round trip.
|
||||||
|
*/
|
||||||
|
assign: (body: { tenantid: number; userid: number; locationid: number }) =>
|
||||||
|
api.put<unknown>(`${WEB}/tenants/assignstaff`, body),
|
||||||
|
|
||||||
|
unassign: (body: { tenantid: number; userid: number }) =>
|
||||||
|
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Update a person.
|
||||||
@@ -122,11 +193,23 @@ export const posUsersApi = {
|
|||||||
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
* non-array and hands back `[]`, so the page showed "no till accounts" for a
|
||||||
* shop that had them. Same shape trap as `/health/location`.
|
* shop that had them. Same shape trap as `/health/location`.
|
||||||
*/
|
*/
|
||||||
list: (tenantid: number, locationid: number) =>
|
list: (tenantid: number, locationid: number, includeInactive = false) =>
|
||||||
api
|
api
|
||||||
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
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 : [])),
|
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
|
||||||
|
|
||||||
@@ -155,13 +238,46 @@ export const posUsersApi = {
|
|||||||
*
|
*
|
||||||
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
|
* 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
|
api
|
||||||
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
|
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
...(locationid ? { locationid } : {}),
|
||||||
})
|
})
|
||||||
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
|
.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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
* know about it.
|
* know about it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api, WEB } from './client';
|
import { api, MOB, WEB } from './client';
|
||||||
import type {
|
import type {
|
||||||
ImportCatalogueProductRequest,
|
ImportCatalogueProductRequest,
|
||||||
Product,
|
Product,
|
||||||
@@ -29,6 +29,8 @@ import type {
|
|||||||
ProductStockRequest,
|
ProductStockRequest,
|
||||||
ProductSubCategory,
|
ProductSubCategory,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
|
||||||
|
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
|
||||||
|
|
||||||
export interface LocationProductQuery {
|
export interface LocationProductQuery {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
@@ -38,13 +40,25 @@ export interface LocationProductQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const productsApi = {
|
export const productsApi = {
|
||||||
/** A store's own catalogue — what is actually imported, with live stock. */
|
/**
|
||||||
|
* A store's own catalogue — what is actually imported, with live stock.
|
||||||
|
*
|
||||||
|
* `pageno` is 1-BASED on the backend: `GetLocationProducts` clamps anything
|
||||||
|
* below 1 up to 1 (`productRepository.go:453`). So page 0 and page 1 both
|
||||||
|
* return the first page, and a caller counting from zero fetches page one
|
||||||
|
* twice and never sees the last one. The `+ 1` here is what makes a 0-based
|
||||||
|
* caller correct rather than off by one.
|
||||||
|
*
|
||||||
|
* `pagesize` defaults to 200 rather than 50 because nothing in the console
|
||||||
|
* paginates this yet: both call sites ask for one page and render it, so a
|
||||||
|
* shop with 80 products was showing 50 and silently dropping the rest.
|
||||||
|
*/
|
||||||
locationProducts: (query: LocationProductQuery) =>
|
locationProducts: (query: LocationProductQuery) =>
|
||||||
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
pageno: query.pageno ?? 0,
|
pageno: (query.pageno ?? 0) + 1,
|
||||||
pagesize: query.pagesize ?? 50,
|
pagesize: query.pagesize ?? 200,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +71,11 @@ export const productsApi = {
|
|||||||
* SKU lookup in `importSheetProducts` then read as a product with no
|
* SKU lookup in `importSheetProducts` then read as a product with no
|
||||||
* `productid`: every sheet import resolved zero ids and wrote no locations
|
* `productid`: every sheet import resolved zero ids and wrote no locations
|
||||||
* and no stock. Flattened here so no caller sees the grouping.
|
* 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) =>
|
allProducts: (tenantid: number) =>
|
||||||
api
|
api
|
||||||
@@ -79,7 +98,35 @@ export const productsApi = {
|
|||||||
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
|
||||||
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
|
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>) =>
|
createProduct: (product: Partial<Product>) =>
|
||||||
api.post<Product>(`${WEB}/products/create`, product),
|
api.post<Product>(`${WEB}/products/create`, product),
|
||||||
|
|
||||||
@@ -129,6 +176,33 @@ export const productsApi = {
|
|||||||
{ tenantid },
|
{ tenantid },
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges category NAMES for this tenant's category ids, creating any that
|
||||||
|
* do not exist yet.
|
||||||
|
*
|
||||||
|
* POST because it writes: a sheet naming an aisle this shop has never stocked
|
||||||
|
* opens the aisle rather than failing. Keyed on the lowercased, trimmed name,
|
||||||
|
* so a caller looks up whatever casing its own sheet used.
|
||||||
|
*/
|
||||||
|
resolveCategories: (tenantid: number, names: string[]) =>
|
||||||
|
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-files products into different aisles, in bulk.
|
||||||
|
*
|
||||||
|
* `categoryid` should be 2 on every row — the customer app FILTERS on it and
|
||||||
|
* anything else removes the product from its browse. `subcategoryid` is the
|
||||||
|
* one that decides the heading a shopper reads; 0 leaves whatever the product
|
||||||
|
* already has, so a caller that does not know the aisle cannot erase one.
|
||||||
|
*
|
||||||
|
* Scoped by tenant on the server as well as here — a productid is global, so
|
||||||
|
* a wrong id in the list would otherwise move another merchant's product.
|
||||||
|
*/
|
||||||
|
recategorise: (
|
||||||
|
tenantid: number,
|
||||||
|
updates: { productid: number; categoryid: number; subcategoryid?: number }[],
|
||||||
|
) => api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }),
|
||||||
|
|
||||||
/** Unlinks from the store. The product row and its order history survive. */
|
/** Unlinks from the store. The product row and its order history survive. */
|
||||||
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
|
||||||
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
|
||||||
@@ -142,6 +216,13 @@ export const productsApi = {
|
|||||||
export interface SheetProductRow {
|
export interface SheetProductRow {
|
||||||
productname: string;
|
productname: string;
|
||||||
productsku: string;
|
productsku: string;
|
||||||
|
/**
|
||||||
|
* The category NAME, from the ladder in `productCategory.ts`.
|
||||||
|
*
|
||||||
|
* The name is what the pipeline and the catalogue both speak; the id is
|
||||||
|
* per-tenant and is resolved from this at import time.
|
||||||
|
*/
|
||||||
|
category?: string;
|
||||||
categoryid: number;
|
categoryid: number;
|
||||||
subcategoryid: number;
|
subcategoryid: number;
|
||||||
retailprice: number;
|
retailprice: number;
|
||||||
@@ -197,14 +278,41 @@ export async function importSheetProducts(
|
|||||||
const failures: SheetImportResult['failures'] = [];
|
const failures: SheetImportResult['failures'] = [];
|
||||||
const createdSkus: string[] = [];
|
const createdSkus: string[] = [];
|
||||||
|
|
||||||
|
/*
|
||||||
|
The aisles first, in one call, before a single product is created.
|
||||||
|
|
||||||
|
Every row carries a category NAME worked out by the ladder in
|
||||||
|
`productCategory.ts`. What the customer app groups by is not that category
|
||||||
|
but `products.subcategoryid` — one of ten platform rows under category 2 —
|
||||||
|
so the name is folded into an aisle and the aisle looked up by name here. See
|
||||||
|
`appAisle.ts` for the endpoint that is measured against.
|
||||||
|
|
||||||
|
A failure here is not fatal: the lookup falls back to the ids last read from
|
||||||
|
the platform, and a row that still cannot be placed is created with
|
||||||
|
subcategoryid 0, which the app lists under "Uncategorized".
|
||||||
|
*/
|
||||||
|
let aisleIds: ReadonlyMap<string, number>;
|
||||||
|
try {
|
||||||
|
aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY));
|
||||||
|
} catch {
|
||||||
|
aisleIds = aisleIdsFrom(undefined);
|
||||||
|
}
|
||||||
|
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()) {
|
for (const [index, row] of rows.entries()) {
|
||||||
try {
|
try {
|
||||||
await productsApi.createProduct({
|
const created = await productsApi.createProduct({
|
||||||
tenantid,
|
tenantid,
|
||||||
productname: row.productname,
|
productname: row.productname,
|
||||||
productsku: row.productsku,
|
productsku: row.productsku,
|
||||||
categoryid: row.categoryid,
|
// ALWAYS 2 — the app filters on it and would drop anything else. The
|
||||||
subcategoryid: row.subcategoryid,
|
// aisle a shopper reads is the subcategory.
|
||||||
|
categoryid: APP_BROWSE_CATEGORY,
|
||||||
|
subcategoryid: subcategoryIdFor(row),
|
||||||
retailprice: row.retailprice,
|
retailprice: row.retailprice,
|
||||||
productcost: row.productcost,
|
productcost: row.productcost,
|
||||||
taxpercent: row.taxpercent,
|
taxpercent: row.taxpercent,
|
||||||
@@ -214,49 +322,59 @@ export async function importSheetProducts(
|
|||||||
productdesc: row.productdesc,
|
productdesc: row.productdesc,
|
||||||
productstatus: 'Active',
|
productstatus: 'Active',
|
||||||
});
|
});
|
||||||
createdSkus.push(row.productsku);
|
|
||||||
} catch (error) {
|
/*
|
||||||
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
|
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);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createdSkus.push(row.productsku);
|
||||||
locationRows.push({
|
locationRows.push({
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
locationid,
|
||||||
productid: product.productid,
|
productid: created.productid,
|
||||||
price: row.retailprice,
|
price: row.retailprice,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
});
|
});
|
||||||
stockRows.push({
|
stockRows.push({
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
locationid,
|
||||||
productid: product.productid,
|
productid: created.productid,
|
||||||
quantity: row.quantity,
|
quantity: row.quantity,
|
||||||
stocktype: 'in',
|
stocktype: 'in',
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
|
||||||
|
}
|
||||||
|
onProgress?.(index + 1, rows.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
|
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
|
||||||
@@ -269,3 +387,66 @@ export async function importSheetProducts(
|
|||||||
failures,
|
failures,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Variants: one product, several sizes ────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A size under a parent product.
|
||||||
|
*
|
||||||
|
* `variantproductid` is a REAL product row — its own price, its own stock, its
|
||||||
|
* own barcode — which is why a variant carries none of them. That is the whole
|
||||||
|
* design: "Cadbury 5 Star 18g" and "9.8g" stay two products the shop counts
|
||||||
|
* separately, and the app shows one card with a size picker.
|
||||||
|
*
|
||||||
|
* `variantname` is what the picker shows. It is free text rather than derived
|
||||||
|
* from the product name, because "Aachi Baby Fryums 500g" should read as "500g"
|
||||||
|
* in a row of three buttons, not repeat the brand three times.
|
||||||
|
*/
|
||||||
|
export interface ProductVariantLink {
|
||||||
|
variantid?: number;
|
||||||
|
tenantid: number;
|
||||||
|
/** The parent — the product the app shows. */
|
||||||
|
productid: number;
|
||||||
|
/** The product actually added to the basket for this size. */
|
||||||
|
variantproductid: number;
|
||||||
|
variantname: string;
|
||||||
|
varianttype?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const variantsApi = {
|
||||||
|
/**
|
||||||
|
* Group a product under a parent.
|
||||||
|
*
|
||||||
|
* The backend refuses a self-reference, a parent or child belonging to
|
||||||
|
* another tenant, and a duplicate link — so the console does not need to
|
||||||
|
* re-check any of that, only to show the reason.
|
||||||
|
*/
|
||||||
|
add: (link: ProductVariantLink) =>
|
||||||
|
api.post<ProductVariantLink>(`${WEB}/products/addproductvariant`, link),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ungroup. The product itself is untouched — only the link goes.
|
||||||
|
*
|
||||||
|
* Keyed on `variantid`, the link's own id, not on the two product ids. The
|
||||||
|
* backend refuses anything else with "tenantid and variantid are both
|
||||||
|
* required", so the caller has to have read the link before it can drop it.
|
||||||
|
*/
|
||||||
|
remove: (params: { tenantid: number; variantid: number }) =>
|
||||||
|
api.del<unknown>(`${WEB}/products/removeproductvariant`, undefined, params),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sizes under one product, as the customer app receives them.
|
||||||
|
*
|
||||||
|
* `/v1/mob`, not `/v1/web` — this endpoint exists only on the mobile group, and
|
||||||
|
* calling the web path 404s. Reading it from the console is deliberate: it is
|
||||||
|
* the only way to show a merchant exactly what a shopper will see, rather than
|
||||||
|
* a second rendering of the same links that can drift from it.
|
||||||
|
*
|
||||||
|
* The first entry is the PARENT ITSELF. A parent is one of its own sizes, so a
|
||||||
|
* picker of three has three entries, not a parent plus two.
|
||||||
|
*/
|
||||||
|
export const variantPreviewApi = {
|
||||||
|
forProduct: (params: { productid: number; tenantid: number; locationid: number }) =>
|
||||||
|
api.list<Product>(`${MOB}/products/getproductbyvariant`, params),
|
||||||
|
};
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
@@ -22,8 +22,20 @@ import type { StockRequest, StockStatementRow } from './types';
|
|||||||
* read back, which is enough to drive a queue but is not a state machine — the
|
* read back, which is enough to drive a queue but is not a state machine — the
|
||||||
* backend will accept any string at all.
|
* backend will accept any string at all.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The ladder a request climbs.
|
||||||
|
*
|
||||||
|
* `Approved` sits between asking and arriving, and adding it is the point:
|
||||||
|
* approving used to put the stock on the shelf immediately, so the count said
|
||||||
|
* the goods were there from the moment the admin agreed to send them — which is
|
||||||
|
* days before they arrive, and the branch sells against a shelf that is empty.
|
||||||
|
*
|
||||||
|
* Only `Received` moves the ledger. Fiesta keys the stock write on that exact
|
||||||
|
* word, so `Approved` is a status and nothing else.
|
||||||
|
*/
|
||||||
export const STOCK_REQUEST_STATUS = {
|
export const STOCK_REQUEST_STATUS = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
received: 'Received',
|
received: 'Received',
|
||||||
rejected: 'Rejected',
|
rejected: 'Rejected',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -40,6 +52,18 @@ export interface StockRequestQuery {
|
|||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a batch actually did.
|
||||||
|
*
|
||||||
|
* Both lists are always read: a batch that half-worked is the case worth
|
||||||
|
* reporting, and the failures name the row so somebody can go and look.
|
||||||
|
*/
|
||||||
|
export interface StockBatchOutcome {
|
||||||
|
updated?: number[];
|
||||||
|
created?: unknown[];
|
||||||
|
failed?: { requestid?: number; productid?: number; reason: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateStockRequest {
|
export interface CreateStockRequest {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
@@ -80,18 +104,61 @@ export const stockApi = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approve a request — which means marking it Received.
|
* Agree to send the stock. Nothing reaches the shelf yet.
|
||||||
*
|
*
|
||||||
* This adds `request.qty` to the branch's stock. There is no way to approve a
|
* The shelf is written when the branch confirms the goods ARRIVED, not when
|
||||||
* different amount: the service reads the quantity off the request row, not
|
* the admin agrees to send them — see `confirmArrival`.
|
||||||
* off this call.
|
|
||||||
*/
|
*/
|
||||||
approve: (requestid: number) =>
|
approve: (requestid: number) =>
|
||||||
|
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||||
|
requestid,
|
||||||
|
status: STOCK_REQUEST_STATUS.approved,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The goods turned up. THIS is what adds `request.qty` to the branch's stock.
|
||||||
|
*
|
||||||
|
* There is no way to receive a different amount: the service reads the
|
||||||
|
* quantity off the request row, not off this call. A short delivery has to be
|
||||||
|
* corrected on the stock ledger afterwards.
|
||||||
|
*/
|
||||||
|
confirmArrival: (requestid: number) =>
|
||||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||||
requestid,
|
requestid,
|
||||||
status: STOCK_REQUEST_STATUS.received,
|
status: STOCK_REQUEST_STATUS.received,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several requests at once.
|
||||||
|
*
|
||||||
|
* One call rather than a loop of them, because approving MOVES STOCK: a loop
|
||||||
|
* that dies halfway leaves some deliveries received and some not, with nothing
|
||||||
|
* to say which. The backend applies each id separately and reports both lists,
|
||||||
|
* so a partial outcome is a fact the screen can show rather than a guess.
|
||||||
|
*
|
||||||
|
* The same status for the whole batch, never a mix. "Approve these" and
|
||||||
|
* "reject these" are two decisions, and one call that could do both is how a
|
||||||
|
* mis-click approves what it meant to refuse.
|
||||||
|
*/
|
||||||
|
decideMany: (requestids: number[], status: StockRequestStatus) =>
|
||||||
|
api.put<StockBatchOutcome>(`${WEB}/products/updatestockrequest`, { requestids, status }),
|
||||||
|
|
||||||
|
approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.approved),
|
||||||
|
confirmArrivalMany: (requestids: number[]) =>
|
||||||
|
stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received),
|
||||||
|
rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A branch asks for several products in one go.
|
||||||
|
*
|
||||||
|
* Restocking after a delivery is one errand, not twenty. Sending it as twenty
|
||||||
|
* calls is slow, and a dropped connection leaves a half-made request list that
|
||||||
|
* nobody can tell apart from a deliberate one.
|
||||||
|
*/
|
||||||
|
createMany: (rows: CreateStockRequest[]) =>
|
||||||
|
api.post<StockBatchOutcome>(`${WEB}/products/createstockrequest`,
|
||||||
|
rows.map((row) => ({ ...row, status: STOCK_REQUEST_STATUS.pending }))),
|
||||||
|
|
||||||
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
|
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
|
||||||
reject: (requestid: number) =>
|
reject: (requestid: number) =>
|
||||||
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
|
||||||
|
|||||||
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
|
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
|
||||||
|
|
||||||
import { api, WEB } from './client';
|
import { api, WEB } from './client';
|
||||||
|
import type { AppLocation } from './deliveries';
|
||||||
|
import type { InviteOutcome } from './people';
|
||||||
import type { TenantInfo, TenantLocation } from './types';
|
import type { TenantInfo, TenantLocation } from './types';
|
||||||
|
|
||||||
/** Everything the tenant-onboarding form collects. */
|
/** Everything the tenant-onboarding form collects. */
|
||||||
@@ -9,6 +11,21 @@ export interface CreateTenantRequest {
|
|||||||
companyname: string;
|
companyname: string;
|
||||||
primarycontact: string;
|
primarycontact: string;
|
||||||
primaryemail: string;
|
primaryemail: string;
|
||||||
|
/**
|
||||||
|
* Who runs the shop — `tenants.firstname`.
|
||||||
|
*
|
||||||
|
* Asked here because here is the only moment it can be asked. The primary
|
||||||
|
* branch and the merchant's own admin login are both created inside
|
||||||
|
* `CreateTenantUser`'s transaction, and the login is copied from the tenant
|
||||||
|
* row — so a name given now names the business AND the account that will
|
||||||
|
* sign in. Left out, both are blank, which is what every merchant on the
|
||||||
|
* platform currently has: the store profile prints "—" for Store admin and
|
||||||
|
* the account carries no person's name at all.
|
||||||
|
*
|
||||||
|
* One field, not two: `tenants` has a `firstname` column and no
|
||||||
|
* `lastname`, so this is the whole name.
|
||||||
|
*/
|
||||||
|
firstname?: string;
|
||||||
locationname: string;
|
locationname: string;
|
||||||
categoryid: number;
|
categoryid: number;
|
||||||
subcategoryid?: number;
|
subcategoryid?: number;
|
||||||
@@ -28,6 +45,31 @@ export interface CreateTenantRequest {
|
|||||||
/** Everything the branch-onboarding form collects. */
|
/** Everything the branch-onboarding form collects. */
|
||||||
export interface CreateBranchRequest {
|
export interface CreateBranchRequest {
|
||||||
tenantid: number;
|
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;
|
locationname: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
contactno?: string;
|
contactno?: string;
|
||||||
@@ -43,6 +85,35 @@ export interface CreateBranchRequest {
|
|||||||
deliveryradius?: number;
|
deliveryradius?: number;
|
||||||
deliverymins?: number;
|
deliverymins?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/**
|
||||||
|
* Who will run this outlet — an existing person, when one has been hired
|
||||||
|
* already.
|
||||||
|
*
|
||||||
|
* Omitted, the backend spawns a login named after the SHOP, on the shop's
|
||||||
|
* email address, one per outlet. That was the only option, and it is why two
|
||||||
|
* people at a counter shared a credential and nothing recorded which of them
|
||||||
|
* did anything.
|
||||||
|
*
|
||||||
|
* A branch must still arrive with SOMEBODY: name a person here, or give an
|
||||||
|
* `email` to spawn one from. The backend refuses a branch with neither,
|
||||||
|
* because an outlet nobody can sign in to is a dead end that shows up in
|
||||||
|
* every list and is noticed by whoever is standing in the shop.
|
||||||
|
*/
|
||||||
|
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 {
|
export interface TenantListQuery {
|
||||||
@@ -110,18 +181,115 @@ export const tenantsApi = {
|
|||||||
api.post<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
|
api.post<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Commissions a branch and spawns its login (roleid 0, empty password).
|
* Commissions a branch, and gives it somebody to run it.
|
||||||
|
*
|
||||||
|
* Pass `operatorid` to place a person you have already hired. Without it the
|
||||||
|
* backend spawns a login named after the shop, as it always did — kept so
|
||||||
|
* nothing existing changes, but the named person is the better path.
|
||||||
*
|
*
|
||||||
* `createtenantlocation`, not `createlocation`: only this one returns the
|
* `createtenantlocation`, not `createlocation`: only this one returns the
|
||||||
* created row, and the new `locationid` is what a QR code and every
|
* 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
|
* follow-up write need. `createlocation` answers 201 with a message and no
|
||||||
* `details` at all.
|
* `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) =>
|
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
|
||||||
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
|
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 }) =>
|
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
|
||||||
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A merchant editing their own business record.
|
||||||
|
*
|
||||||
|
* The first write path `tenants` has ever had. Before it, everything about a
|
||||||
|
* shop — its name, its photograph, its licence, how to reach it — was set
|
||||||
|
* once at onboarding by a Nearle Admin and could never be changed by anyone.
|
||||||
|
*
|
||||||
|
* Only merchant-owned columns are written; the backend keeps the allowlist
|
||||||
|
* and ignores the rest, so `approved`, `status`, `partnerid` and the billing
|
||||||
|
* fields cannot be set from here even if a caller sends them. Anything
|
||||||
|
* omitted is left alone rather than blanked.
|
||||||
|
*/
|
||||||
|
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
|
||||||
|
api.put<unknown>(`${WEB}/tenants/updatetenant`, body),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which delivery partner supplies this merchant's riders.
|
||||||
|
*
|
||||||
|
* Its own endpoint, not a field on `updateProfile`: `partnerid` is kept out
|
||||||
|
* of the merchant-editable allowlist on purpose, because a merchant who could
|
||||||
|
* set it would move themselves under another partner's riders and billing.
|
||||||
|
*
|
||||||
|
* `partnerid: 0` is a real instruction — it means "this merchant uses their
|
||||||
|
* own riders" — and the server reads it as sent rather than as absent.
|
||||||
|
*/
|
||||||
|
assignPartner: (tenantid: number, partnerid: number) =>
|
||||||
|
api.put<unknown>(`${WEB}/tenants/assignpartner`, { tenantid, partnerid }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One business, by id — how a store login reads its own record.
|
||||||
|
*
|
||||||
|
* Not `listAll`. That is `getalltenants`, paginated over 262 merchants, so a
|
||||||
|
* shop on page two was simply absent and a profile screen built on it would
|
||||||
|
* show nothing for no visible reason.
|
||||||
|
*/
|
||||||
|
byId: (tenantid: number) => api.get<TenantInfo>(`${WEB}/tenants/gettenantinfo`, { tenantid }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Somebody editing their own name, mobile or email.
|
||||||
|
*
|
||||||
|
* Not `users/update`. That one writes whatever struct it is handed and checks
|
||||||
|
* only the userid — no tenant, no guard on role or branch — so a self-service
|
||||||
|
* form built on it would let a branch user promote themselves or move shop.
|
||||||
|
* This is scoped to the caller's own account AND business, and writes
|
||||||
|
* identity fields only.
|
||||||
|
*/
|
||||||
|
updateOwnProfile: (body: {
|
||||||
|
userid: number;
|
||||||
|
tenantid: number;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
contactno?: string;
|
||||||
|
email?: string;
|
||||||
|
}) => api.put<unknown>(`${WEB}/tenants/updateownprofile`, body),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open or close a branch for the day.
|
||||||
|
*
|
||||||
|
* `isopen: false` with no `closeduntil` means closed until somebody says
|
||||||
|
* otherwise — the right shape for a power cut nobody can put an end to.
|
||||||
|
* With a date, the branch reopens on that date BY ITSELF: the server works
|
||||||
|
* the answer out on read rather than running a job, so nobody has to
|
||||||
|
* remember.
|
||||||
|
*
|
||||||
|
* Reopening clears any date, so a branch switched back on cannot keep a
|
||||||
|
* stale "reopens on" that would read as still closed.
|
||||||
|
*
|
||||||
|
* Separate from the branch's `status`, which is how Nearle decommissions a
|
||||||
|
* branch. A day off must not look like a shop that shut down.
|
||||||
|
*/
|
||||||
|
setStoreOpen: (body: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
isopen: boolean;
|
||||||
|
/** "YYYY-MM-DD", the first day back. Omitted when reopening. */
|
||||||
|
closeduntil?: string;
|
||||||
|
}) => api.put<unknown>(`${WEB}/tenants/storeopen`, body),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,6 +307,9 @@ function toTenantBody(form: CreateTenantRequest): Record<string, unknown> {
|
|||||||
companyname: form.companyname,
|
companyname: form.companyname,
|
||||||
primaryemail: form.primaryemail,
|
primaryemail: form.primaryemail,
|
||||||
primarycontact: form.primarycontact,
|
primarycontact: form.primarycontact,
|
||||||
|
// Written to `tenants.firstname` and copied onto the admin login the same
|
||||||
|
// transaction creates — see the note on the field.
|
||||||
|
firstname: (form.firstname ?? '').trim(),
|
||||||
categoryid: form.categoryid,
|
categoryid: form.categoryid,
|
||||||
subcategoryid: form.subcategoryid ?? 0,
|
subcategoryid: form.subcategoryid ?? 0,
|
||||||
address: form.address,
|
address: form.address,
|
||||||
@@ -188,4 +359,14 @@ export const utilsApi = {
|
|||||||
* invisible to onboarding until someone edits the frontend.
|
* invisible to onboarding until someone edits the frontend.
|
||||||
*/
|
*/
|
||||||
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
|
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delivery regions — Coimbatore, Madurai, Nagercoil today.
|
||||||
|
*
|
||||||
|
* `applocationid` is REQUIRED by the handler and 0 is how you ask for all of
|
||||||
|
* them; omitting it answers 400 "Invalid applocationid", which reads as a
|
||||||
|
* broken request rather than a missing default.
|
||||||
|
*/
|
||||||
|
appLocations: (applocationid = 0) =>
|
||||||
|
api.list<AppLocation>(`${WEB}/utils/getapplocations`, { applocationid }),
|
||||||
};
|
};
|
||||||
|
|||||||
418
src/api/types.ts
418
src/api/types.ts
@@ -33,6 +33,83 @@ export interface FiestaEnvelope<T> {
|
|||||||
data?: T;
|
data?: T;
|
||||||
/** Present on the tenant login endpoints. */
|
/** Present on the tenant login endpoints. */
|
||||||
tenantform?: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -93,6 +170,14 @@ export interface TenantInfo {
|
|||||||
longitude?: string;
|
longitude?: string;
|
||||||
tenantimage?: string;
|
tenantimage?: string;
|
||||||
tenantinfo?: string;
|
tenantinfo?: string;
|
||||||
|
/**
|
||||||
|
* The FSSAI or trade licence.
|
||||||
|
*
|
||||||
|
* Returned by the customer-facing tenant reads and displayed to shoppers,
|
||||||
|
* and across 200 merchants not one had it filled in. For a food business in
|
||||||
|
* India it is a display requirement, not a nicety.
|
||||||
|
*/
|
||||||
|
licenseno?: string;
|
||||||
partnerid?: number;
|
partnerid?: number;
|
||||||
minorder?: number;
|
minorder?: number;
|
||||||
/** Misspelled on the wire — `applolcationid`, not `applocationid`. */
|
/** Misspelled on the wire — `applolcationid`, not `applocationid`. */
|
||||||
@@ -101,11 +186,28 @@ export interface TenantInfo {
|
|||||||
approved?: number;
|
approved?: number;
|
||||||
moduleid?: number;
|
moduleid?: number;
|
||||||
subcategoryname?: string;
|
subcategoryname?: string;
|
||||||
|
/** The app category by name — "Daily Needs", not "Category 2". */
|
||||||
|
categoryname?: string;
|
||||||
|
/** When the merchant was created. Not returned before Sept 2026. */
|
||||||
|
created?: string;
|
||||||
firstname?: string;
|
firstname?: string;
|
||||||
lastname?: string;
|
lastname?: string;
|
||||||
/** Capitalised on the wire. */
|
/** Capitalised on the wire. */
|
||||||
Accountname?: string;
|
Accountname?: string;
|
||||||
status: 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 {
|
export interface TenantLocation {
|
||||||
@@ -131,6 +233,20 @@ export interface TenantLocation {
|
|||||||
deliverymins?: number;
|
deliverymins?: number;
|
||||||
cancelsecs?: number;
|
cancelsecs?: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
/**
|
||||||
|
* Whether this branch is trading today.
|
||||||
|
*
|
||||||
|
* Separate from `status`, which is whether the branch exists at all —
|
||||||
|
* setting that to Inactive is how a branch is decommissioned. A shopkeeper
|
||||||
|
* closing for an afternoon is saying something else, and conflating the two
|
||||||
|
* would make a day off read as a shop that shut down.
|
||||||
|
*
|
||||||
|
* `closeduntil` is the first day back; the server works out on read whether
|
||||||
|
* that date has passed, so a branch reopens without anybody remembering.
|
||||||
|
*/
|
||||||
|
isopen?: boolean;
|
||||||
|
closeduntil?: string;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -170,6 +286,14 @@ export interface CatalogueBrand {
|
|||||||
export interface CatalogueRef {
|
export interface CatalogueRef {
|
||||||
brand: string;
|
brand: string;
|
||||||
catalogueid: number;
|
catalogueid: number;
|
||||||
|
/**
|
||||||
|
* The catalogue's own stable key, when the product carries one.
|
||||||
|
*
|
||||||
|
* Preferred over `catalogueid` for matching: the id is renumbered by every
|
||||||
|
* re-scrape. Empty for a product imported before the column existed, and
|
||||||
|
* filled in by a re-import or by `products/relinkcatalogue`.
|
||||||
|
*/
|
||||||
|
imageid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -187,16 +311,66 @@ export interface Product {
|
|||||||
/** Capitalised on the wire. */
|
/** Capitalised on the wire. */
|
||||||
Subcategoryname?: string;
|
Subcategoryname?: string;
|
||||||
catalogueid?: number;
|
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;
|
productname?: string;
|
||||||
productimage?: string;
|
productimage?: string;
|
||||||
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
|
||||||
productimages?: string;
|
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;
|
productdesc?: string;
|
||||||
productsku?: string;
|
productsku?: string;
|
||||||
brandid?: number;
|
brandid?: number;
|
||||||
productbrand?: string;
|
productbrand?: string;
|
||||||
|
/**
|
||||||
|
* The stable global-catalogue key, carried across by the import.
|
||||||
|
*
|
||||||
|
* Not `catalogueid`, which is renumbered on every re-scrape — 11 of 19 links
|
||||||
|
* were already broken by that. This is what joins a tenant's product back to
|
||||||
|
* the catalogue, and it is also the key the health-score service uses.
|
||||||
|
*
|
||||||
|
* Empty for anything typed in or imported from a sheet, which is most of the
|
||||||
|
* catalogue: measured 4 Sep 2026, R mart carries it on 21 products of 28,
|
||||||
|
* Suriya Store on 2 of 8, K mart on none.
|
||||||
|
*/
|
||||||
|
imageid?: string;
|
||||||
productunit?: string;
|
productunit?: string;
|
||||||
unitvalue?: string;
|
unitvalue?: string;
|
||||||
|
/**
|
||||||
|
* The size label, present only on rows from `getproductbyvariant`.
|
||||||
|
*
|
||||||
|
* What a shopper taps in the size picker — "500g", not the whole product
|
||||||
|
* name. The backend derives the PARENT's own label from
|
||||||
|
* `unitvalue + productunit` and falls back to the product name when both are
|
||||||
|
* blank, so a product imported without a unit shows its full name in the
|
||||||
|
* picker. Worth knowing when a picker reads badly: the fix is the product's
|
||||||
|
* unit, not the link.
|
||||||
|
*/
|
||||||
|
variantname?: string;
|
||||||
productcost?: number;
|
productcost?: number;
|
||||||
taxamount?: number;
|
taxamount?: number;
|
||||||
taxpercent?: number;
|
taxpercent?: number;
|
||||||
@@ -224,10 +398,23 @@ export interface ProductCategory {
|
|||||||
categoryname: string;
|
categoryname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /web/products/getproductsubcategories`.
|
||||||
|
*
|
||||||
|
* The wire names are `subcatid` and `subcatname` — ABBREVIATED, unlike
|
||||||
|
* `ProductCategory` next door, which spells `categoryid`/`categoryname` in
|
||||||
|
* full. This interface claimed the long forms, so every field read off it was
|
||||||
|
* `undefined` and TypeScript had no way to know: the rows arrive typed by this
|
||||||
|
* declaration, not by what the server sent. `scripts/appgap.mjs` built its
|
||||||
|
* subcategory map from them and got a map of `undefined → undefined`, then
|
||||||
|
* reported "(none returned)" for a category that has six.
|
||||||
|
*/
|
||||||
export interface ProductSubCategory {
|
export interface ProductSubCategory {
|
||||||
subcategoryid: number;
|
subcatid: number;
|
||||||
subcategoryname: string;
|
subcatname: string;
|
||||||
categoryid?: number;
|
categoryid?: number;
|
||||||
|
image?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
|
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
|
||||||
@@ -244,6 +431,17 @@ export interface ImportCatalogueProductRequest {
|
|||||||
retailprice: number;
|
retailprice: number;
|
||||||
productcost: number;
|
productcost: number;
|
||||||
taxpercent: 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. */
|
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
|
||||||
@@ -273,23 +471,53 @@ export interface ProductStockRequest {
|
|||||||
Orders & deliveries — the summary shapes the console reads
|
Orders & deliveries — the summary shapes the console reads
|
||||||
──────────────────────────────────────────────────────────────────────────── */
|
──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export interface OrderSummary {
|
/**
|
||||||
totalorders?: number;
|
* What `getordersummary` and `getlocationsummary` actually send.
|
||||||
delivered?: number;
|
*
|
||||||
|
* ── 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;
|
pending?: number;
|
||||||
|
processing?: number;
|
||||||
|
delivered?: number;
|
||||||
cancelled?: 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;
|
locationid?: number;
|
||||||
locationname?: string;
|
locationname?: string;
|
||||||
totalorders?: number;
|
|
||||||
delivered?: number;
|
|
||||||
cancelled?: number;
|
|
||||||
revenue?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliverySummary {
|
export interface DeliverySummary {
|
||||||
@@ -389,8 +617,17 @@ export interface OrderRow {
|
|||||||
*/
|
*/
|
||||||
ordervalue?: number;
|
ordervalue?: number;
|
||||||
orderamount?: 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;
|
deliverycharge?: number;
|
||||||
deliveryamt?: number;
|
deliveryamt?: number;
|
||||||
paymenttype?: number;
|
paymenttype?: number;
|
||||||
@@ -412,7 +649,14 @@ export interface OrderRow {
|
|||||||
pickupcontactno?: string;
|
pickupcontactno?: string;
|
||||||
pickupaddress?: string;
|
pickupaddress?: string;
|
||||||
pickupsuburb?: string;
|
pickupsuburb?: string;
|
||||||
/** The delivery half. Blank on an order nobody has been assigned to. */
|
/**
|
||||||
|
* The delivery half. Blank on an order nobody has been assigned to.
|
||||||
|
*
|
||||||
|
* `deliveryid` is the one to read for "has a rider been assigned yet" — it is
|
||||||
|
* 0 until `createdeliveries` runs, and it is 0 on every unassigned row in
|
||||||
|
* production. `rider` looks like the same test and is not: it is filled from
|
||||||
|
* a join and is empty on rows that DO have a delivery.
|
||||||
|
*/
|
||||||
deliveryid?: number;
|
deliveryid?: number;
|
||||||
rider?: string;
|
rider?: string;
|
||||||
ridercontactno?: string;
|
ridercontactno?: string;
|
||||||
@@ -421,6 +665,97 @@ export interface OrderRow {
|
|||||||
pickuptime?: string;
|
pickuptime?: string;
|
||||||
deliverytime?: string;
|
deliverytime?: string;
|
||||||
canceltime?: string;
|
canceltime?: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The ids a delivery row has to be built from.
|
||||||
|
*
|
||||||
|
* All of these are on the wire and none were typed, because nothing read
|
||||||
|
* them until assignment existed. `createdeliveries` copies them onto the
|
||||||
|
* `deliveries` row, and the reads that follow join on them — so an order
|
||||||
|
* that arrives without one produces a delivery that cannot be seen. See
|
||||||
|
* `assignDelivery.ts` for which of them are load-bearing.
|
||||||
|
*/
|
||||||
|
applocationid?: number;
|
||||||
|
partnerid?: number;
|
||||||
|
configid?: number;
|
||||||
|
moduleid?: number;
|
||||||
|
categoryid?: number;
|
||||||
|
subcategoryid?: number;
|
||||||
|
/** Who placed the order. Distinct from `deliverycustomerid`, which is 0. */
|
||||||
|
customerid?: number;
|
||||||
|
deliverycustomerid?: number;
|
||||||
|
deliverylocationid?: number;
|
||||||
|
pickuplocationid?: number;
|
||||||
|
pickuplat?: string;
|
||||||
|
pickuplong?: string;
|
||||||
|
deliverylat?: string;
|
||||||
|
deliverylong?: string;
|
||||||
|
droplat?: string;
|
||||||
|
droplon?: string;
|
||||||
|
kms?: string;
|
||||||
|
/**
|
||||||
|
* Empty on every production order, so it CANNOT be used to tell a delivery
|
||||||
|
* order from a collection. Typed only so nobody reaches for it and quietly
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rider, from `GET /partners/getriders` (`models.RiderInfo`).
|
||||||
|
*
|
||||||
|
* Only riders who are ON DUTY TODAY appear. The query requires a `riderlogs`
|
||||||
|
* row stamped today with `logstatus = 0` and `app_userpools.onduty = 1`, so an
|
||||||
|
* empty list means "nobody has clocked on", not "this shop has no riders" —
|
||||||
|
* a distinction the picker has to make, or an operator spends the morning
|
||||||
|
* wondering why the fleet vanished.
|
||||||
|
*
|
||||||
|
* `password` is deliberately absent for the same reason it is on `Staff`: the
|
||||||
|
* endpoint returns it in clear, and not typing it is what stops a cell
|
||||||
|
* rendering one.
|
||||||
|
*/
|
||||||
|
export interface RiderInfo {
|
||||||
|
userid: number;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
fullname?: string;
|
||||||
|
contactno?: string;
|
||||||
|
partnerid?: number;
|
||||||
|
applocationid?: number;
|
||||||
|
applocation?: string;
|
||||||
|
vehiclename?: string;
|
||||||
|
vehicleno?: string;
|
||||||
|
licenseno?: string;
|
||||||
|
/** The device to push to. Empty for a rider who has never opened the app. */
|
||||||
|
userfcmtoken?: string;
|
||||||
|
shiftid?: number;
|
||||||
|
/** Shift window, as `HH:MM:SS`. */
|
||||||
|
starttime?: string;
|
||||||
|
endtime?: string;
|
||||||
|
/** Today's log. `logstatus` 0 is on duty. */
|
||||||
|
logdate?: string;
|
||||||
|
login?: string;
|
||||||
|
logout?: string;
|
||||||
|
logstatus?: number;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -596,6 +931,19 @@ export interface DeliveryRow {
|
|||||||
deliverytime?: string;
|
deliverytime?: string;
|
||||||
canceltime?: string;
|
canceltime?: string;
|
||||||
expecteddeliverytime?: 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;
|
itemcount?: number;
|
||||||
orderamount?: number;
|
orderamount?: number;
|
||||||
pickupcustomer?: string;
|
pickupcustomer?: string;
|
||||||
@@ -605,6 +953,14 @@ export interface DeliveryRow {
|
|||||||
pickupaddress?: string;
|
pickupaddress?: string;
|
||||||
pickuplocation?: string;
|
pickuplocation?: string;
|
||||||
pickupsuburb?: string;
|
pickupsuburb?: string;
|
||||||
|
/**
|
||||||
|
* Present on the wire and ALWAYS 0. Nothing that writes a delivery sets it —
|
||||||
|
* not the app, not `createdeliveries` — so joining a delivery to a customer
|
||||||
|
* on this id matches nothing. `GetTenantLocationDeliveries` did exactly that
|
||||||
|
* with an INNER JOIN and returned zero rows for every branch as a result.
|
||||||
|
* Group on `deliverycontactno` instead; see `dispatchModel.groupByCustomer`.
|
||||||
|
*/
|
||||||
|
deliverycustomerid?: number;
|
||||||
deliverycustomer?: string;
|
deliverycustomer?: string;
|
||||||
deliverycontactno?: string;
|
deliverycontactno?: string;
|
||||||
deliveryaddress?: string;
|
deliveryaddress?: string;
|
||||||
@@ -616,8 +972,25 @@ export interface DeliveryRow {
|
|||||||
deliverytype?: string;
|
deliverytype?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
ordernotes?: string;
|
ordernotes?: string;
|
||||||
|
/**
|
||||||
|
* The assigned rider's `app_users.userid`.
|
||||||
|
*
|
||||||
|
* The stable key for grouping a round — `ridername` comes from a join and is
|
||||||
|
* blank whenever that join misses.
|
||||||
|
*/
|
||||||
|
userid?: number;
|
||||||
ridername?: string;
|
ridername?: string;
|
||||||
ridercontact?: string;
|
ridercontact?: string;
|
||||||
|
/**
|
||||||
|
* Where the rider was when they last moved this job along.
|
||||||
|
*
|
||||||
|
* Written by `updatedelivery`, so a position arrives per status change — a
|
||||||
|
* handful of points per delivery, never a trail. Nothing ingests positions on
|
||||||
|
* a timer and `getriders` returns none, so this is the only rider location
|
||||||
|
* the console can show. Sparse: 1 of 7 production deliveries carries one.
|
||||||
|
*/
|
||||||
|
riderslat?: string;
|
||||||
|
riderslon?: string;
|
||||||
/** Planned distance. `actualkms`/`riderkms` is what was really ridden. */
|
/** Planned distance. `actualkms`/`riderkms` is what was really ridden. */
|
||||||
kms?: string;
|
kms?: string;
|
||||||
actualkms?: string;
|
actualkms?: string;
|
||||||
@@ -662,6 +1035,19 @@ export interface StaffInfo {
|
|||||||
locationid?: number;
|
locationid?: number;
|
||||||
locationname?: string;
|
locationname?: string;
|
||||||
status?: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
100
src/api/uploads.test.ts
Normal file
100
src/api/uploads.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* The receipt, and the two things it has to get right.
|
||||||
|
*
|
||||||
|
* A receipt exists because the ingest service's batch id is the only credential
|
||||||
|
* for reading a result back, it is handed out once, and an unreviewed drop is
|
||||||
|
* deleted after seven days. So the tests that matter are about not losing that
|
||||||
|
* window, and about the label their admin reads when deciding whether to
|
||||||
|
* approve the file.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { buildSender, daysUntilExpiry, DROP_RETENTION_DAYS, type UploadReceipt } from './uploads';
|
||||||
|
|
||||||
|
function receipt(overrides: Partial<UploadReceipt> = {}): UploadReceipt {
|
||||||
|
return {
|
||||||
|
uploadid: 1,
|
||||||
|
tenantid: 1141,
|
||||||
|
locationid: 1180,
|
||||||
|
categoryid: 2,
|
||||||
|
batchid: '49a82536866a483a9189954d3c749243',
|
||||||
|
runid: '',
|
||||||
|
filename: 'kmart-opening.xlsx',
|
||||||
|
sender: 'Kmart · Peelamedu · abhishek',
|
||||||
|
uploadedby: 1475,
|
||||||
|
uploadedname: 'abhishek',
|
||||||
|
rowcount: 20,
|
||||||
|
laststatus: 'pending',
|
||||||
|
inserted: 0,
|
||||||
|
backfilled: 0,
|
||||||
|
skipped: 0,
|
||||||
|
rejected: 0,
|
||||||
|
shelvedcount: 0,
|
||||||
|
skippedcount: 0,
|
||||||
|
shelvedat: null,
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
|
||||||
|
|
||||||
|
test('a drop uploaded today has the full window left', () => {
|
||||||
|
assert.equal(daysUntilExpiry(receipt()), DROP_RETENTION_DAYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the window closes as the drop sits unreviewed', () => {
|
||||||
|
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(5) })), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Never negative. A drop past the deadline is gone, and "-3 days left" would
|
||||||
|
// read as a countdown that is still running.
|
||||||
|
test('an expired drop reports zero, not a negative', () => {
|
||||||
|
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(30) })), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Retention applies to a drop nobody acted on. Once an admin releases it the run
|
||||||
|
is the record and the drop's own expiry is irrelevant — showing a countdown on
|
||||||
|
a released upload would push someone to chase a deadline that has already been
|
||||||
|
met.
|
||||||
|
*/
|
||||||
|
test('a released drop has no expiry to report', () => {
|
||||||
|
assert.equal(daysUntilExpiry(receipt({ runid: '8dcef8a2ad94', laststatus: 'retired' })), null);
|
||||||
|
assert.equal(daysUntilExpiry(receipt({ laststatus: 'done' })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a receipt with an unreadable date reports nothing rather than guessing', () => {
|
||||||
|
assert.equal(daysUntilExpiry(receipt({ created: 'not a date' })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
The sender label. Free text on their side, capped at 60 characters, and shown to
|
||||||
|
the admin who decides whether to run the file — so it has to identify the shop,
|
||||||
|
not the console.
|
||||||
|
*/
|
||||||
|
test('the sender names the merchant, the branch and the person', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildSender({ tenantname: 'Kmart', locationname: 'Peelamedu', username: 'abhishek' }),
|
||||||
|
'Kmart · Peelamedu · abhishek',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The tenant name is truncated, not the whole label. Cutting the tail would
|
||||||
|
// drop the branch and the person — the two parts that say WHICH shelf and WHO —
|
||||||
|
// and leave only a long restaurant name that identifies neither.
|
||||||
|
test('a long merchant name is trimmed so the branch and person survive', () => {
|
||||||
|
const sender = buildSender({
|
||||||
|
tenantname: 'Ninhao The New Age Chinese Restaurant',
|
||||||
|
locationname: 'Race Course',
|
||||||
|
username: 'abhishek',
|
||||||
|
});
|
||||||
|
assert.ok(sender.length <= 60, `sender was ${sender.length} chars: ${sender}`);
|
||||||
|
assert.ok(sender.includes('Race Course'), sender);
|
||||||
|
assert.ok(sender.includes('abhishek'), sender);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sender with nothing to say still labels the console', () => {
|
||||||
|
assert.equal(buildSender({}), 'nearle-console');
|
||||||
|
});
|
||||||
220
src/api/uploads.ts
Normal file
220
src/api/uploads.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Receipts for spreadsheets sent to the catalogue ingest service.
|
||||||
|
*
|
||||||
|
* This is OUR record, in Fiesta — not the ingest service's. The two are read
|
||||||
|
* together and neither is redundant:
|
||||||
|
*
|
||||||
|
* - **Fiesta** knows the batch id, which shop the sheet was for, who sent it,
|
||||||
|
* and whether the products reached that shop's shelf. None of which the
|
||||||
|
* ingest service has any concept of — it writes the shared global
|
||||||
|
* catalogue and has no tenant and no branch.
|
||||||
|
* - **The ingest service** knows what became of the drop, and is the only
|
||||||
|
* authority on that.
|
||||||
|
*
|
||||||
|
* Fiesta's status columns are a CACHE of the second, written by whichever
|
||||||
|
* browser last polled. They exist so a list of twenty receipts renders without
|
||||||
|
* twenty network calls to a host that spends minutes per batch; the live read
|
||||||
|
* is what any single receipt is judged by.
|
||||||
|
*
|
||||||
|
* ── Why the receipt has to exist at all ──────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Three facts from the ingest service's own documentation, and any one of them
|
||||||
|
* would be enough:
|
||||||
|
*
|
||||||
|
* 1. **The batch id is the credential.** `GET /api/uploads/catalog/{id}` is
|
||||||
|
* anonymous by design — holding the id is the proof of having sent the
|
||||||
|
* drop. Handed out once, to one browser. Lose it and the result is
|
||||||
|
* unreadable by anyone, including whoever uploaded the file.
|
||||||
|
* 2. **An unreviewed drop is deleted after seven days.** Nothing runs on
|
||||||
|
* arrival; a drop waits for an admin to press Start. If nobody does, the
|
||||||
|
* evidence expires.
|
||||||
|
* 3. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
|
||||||
|
* the credential that sent them, production has no API keys configured at
|
||||||
|
* all, and the only account that could read it is a superuser over their
|
||||||
|
* entire application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api, WEB } from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One upload, as Fiesta stores it.
|
||||||
|
*
|
||||||
|
* The two count groups are deliberately not merged. `inserted` and its
|
||||||
|
* neighbours are the ingest service's — products in the GLOBAL catalogue, which
|
||||||
|
* every merchant shares and which therefore carries no price and no stock.
|
||||||
|
* `shelvedcount` is ours: priced, on a branch's shelf, with opening stock
|
||||||
|
* recorded. A product can be in the first and not the second, and reporting it
|
||||||
|
* as "added" would tell a shopkeeper they can sell something nobody can buy.
|
||||||
|
*/
|
||||||
|
export interface UploadReceipt {
|
||||||
|
uploadid: number;
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
categoryid: number;
|
||||||
|
|
||||||
|
/** The drop id. The only field here that cannot be reconstructed. */
|
||||||
|
batchid: string;
|
||||||
|
/** The run an admin released the drop into, once they have. */
|
||||||
|
runid: string;
|
||||||
|
|
||||||
|
filename: string;
|
||||||
|
/** The label their review inbox shows. */
|
||||||
|
sender: string;
|
||||||
|
|
||||||
|
uploadedby: number;
|
||||||
|
uploadedname: string;
|
||||||
|
/** Rows we parsed before sending — independent of anything the service says. */
|
||||||
|
rowcount: number;
|
||||||
|
/**
|
||||||
|
* The parsed sheet as JSON, or empty when it was never stored.
|
||||||
|
*
|
||||||
|
* Empty is the interesting case: it means the prices and opening stock are
|
||||||
|
* gone, and the only way to shelve this upload is for somebody to hand the
|
||||||
|
* file over again. Receipts written before this column existed are all in
|
||||||
|
* that state.
|
||||||
|
*/
|
||||||
|
sheetrows?: string;
|
||||||
|
|
||||||
|
/* ── Cached from the ingest service ──────────────────────────────────── */
|
||||||
|
laststatus: string;
|
||||||
|
inserted: number;
|
||||||
|
backfilled: number;
|
||||||
|
skipped: number;
|
||||||
|
rejected: number;
|
||||||
|
|
||||||
|
/* ── Ours ────────────────────────────────────────────────────────────── */
|
||||||
|
shelvedcount: number;
|
||||||
|
skippedcount: number;
|
||||||
|
shelvedat: string | null;
|
||||||
|
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
|
||||||
|
/** Joined for display; a receipt outlives the page that made it. */
|
||||||
|
tenantname?: string;
|
||||||
|
locationname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordUploadBody {
|
||||||
|
/**
|
||||||
|
* The parsed sheet as JSON — SKU, price and opening stock per row.
|
||||||
|
*
|
||||||
|
* Stored so the shelving step can run later, from the Uploads page, without
|
||||||
|
* the original file or the tab that sent it. The prices and opening stock
|
||||||
|
* exist nowhere else: the ingest service's catalogue is shared by every
|
||||||
|
* merchant and carries neither.
|
||||||
|
*/
|
||||||
|
sheetrows?: string;
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
categoryid: number;
|
||||||
|
batchid: string;
|
||||||
|
filename: string;
|
||||||
|
sender: string;
|
||||||
|
uploadedby: number;
|
||||||
|
uploadedname: string;
|
||||||
|
rowcount: number;
|
||||||
|
laststatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadQuery {
|
||||||
|
/** 0 or omitted means every tenant — how a Nearle Admin sees the platform. */
|
||||||
|
tenantid?: number;
|
||||||
|
locationid?: number;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the ingest service keeps a drop nobody has acted on.
|
||||||
|
*
|
||||||
|
* `BATCH_RETENTION_DAYS` on their side. Worth showing rather than discovering:
|
||||||
|
* a drop that expires unreviewed leaves no trace at either end, and the only
|
||||||
|
* remedy — asking an admin to release it — has to happen before the deadline.
|
||||||
|
*/
|
||||||
|
export const DROP_RETENTION_DAYS = 7;
|
||||||
|
|
||||||
|
/** Days left before an unreviewed drop is deleted; null once it has run. */
|
||||||
|
export function daysUntilExpiry(receipt: UploadReceipt): number | null {
|
||||||
|
// Only a drop still sitting in the review inbox expires. Once released, the
|
||||||
|
// run is the record and retention no longer applies to it.
|
||||||
|
if (receipt.runid || receipt.laststatus !== 'pending') return null;
|
||||||
|
const created = Date.parse(receipt.created);
|
||||||
|
if (Number.isNaN(created)) return null;
|
||||||
|
const elapsedDays = (Date.now() - created) / 86_400_000;
|
||||||
|
return Math.max(0, Math.ceil(DROP_RETENTION_DAYS - elapsedDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label the ingest service's admin sees in their review inbox.
|
||||||
|
*
|
||||||
|
* Their field is free text capped at 60 characters, and until now every upload
|
||||||
|
* from this console arrived as the same constant — so an admin deciding what to
|
||||||
|
* approve could not tell one merchant's sheet from another's.
|
||||||
|
*
|
||||||
|
* Safe to make specific precisely because we never send a credential. Their
|
||||||
|
* ownership filter matches `sender` EXACTLY, and a run an admin assembles from
|
||||||
|
* several drops carries a joined list ("alice, bob") — so a credentialed caller
|
||||||
|
* gets a 404 on a run containing their own file. We read anonymously, holding
|
||||||
|
* the id, which is what their documentation tells integrators to do.
|
||||||
|
*
|
||||||
|
* The tenant name is truncated rather than the whole label, so the branch and
|
||||||
|
* the person survive: "Ninhao The New Age Chinese Restaurant" is 36 characters
|
||||||
|
* on its own and would otherwise push everything identifying off the end.
|
||||||
|
*/
|
||||||
|
export function buildSender(parts: {
|
||||||
|
tenantname?: string;
|
||||||
|
locationname?: string;
|
||||||
|
username?: string;
|
||||||
|
}): string {
|
||||||
|
const tenant = (parts.tenantname ?? '').trim().slice(0, 24);
|
||||||
|
const label = [tenant, (parts.locationname ?? '').trim(), (parts.username ?? '').trim()]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
return (label || 'nearle-console').slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadsApi = {
|
||||||
|
/**
|
||||||
|
* Store the receipt. Called the instant the drop is accepted, before polling.
|
||||||
|
*
|
||||||
|
* That timing is the whole point: it is the one moment the batch id is
|
||||||
|
* guaranteed to exist and guaranteed not to have been lost to a closed tab.
|
||||||
|
* Idempotent on `batchid` server-side, so a retry or a second tab is safe.
|
||||||
|
*/
|
||||||
|
record: (body: RecordUploadBody) => api.post<UploadReceipt>(`${WEB}/uploads/record`, body),
|
||||||
|
|
||||||
|
list: (query: UploadQuery = {}) =>
|
||||||
|
api.list<UploadReceipt>(`${WEB}/uploads/list`, {
|
||||||
|
tenantid: query.tenantid ?? 0,
|
||||||
|
locationid: query.locationid ?? 0,
|
||||||
|
pageno: query.pageno ?? 1,
|
||||||
|
pagesize: query.pagesize ?? 50,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Cache what the ingest service last reported, so the next reader need not wait. */
|
||||||
|
updateStatus: (body: {
|
||||||
|
batchid: string;
|
||||||
|
laststatus: string;
|
||||||
|
runid?: string;
|
||||||
|
inserted?: number;
|
||||||
|
backfilled?: number;
|
||||||
|
skipped?: number;
|
||||||
|
rejected?: number;
|
||||||
|
}) => api.put<unknown>(`${WEB}/uploads/update`, body),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply the prices and opening stock for a receipt that has none.
|
||||||
|
*
|
||||||
|
* The rescue path. A receipt filed before the sheet was stored cannot be
|
||||||
|
* shelved from anywhere, because the ingest service holds a catalogue every
|
||||||
|
* merchant shares and it carries neither figure — so the file has to come
|
||||||
|
* back. Sent as JSON so the shelving can then run without it again.
|
||||||
|
*/
|
||||||
|
attachSheet: (body: { batchid: string; sheetrows: string }) =>
|
||||||
|
api.put<unknown>(`/uploads/sheet`, body),
|
||||||
|
|
||||||
|
/** Record the other half: priced, shelved and stocked at a branch. */
|
||||||
|
markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
|
||||||
|
api.put<unknown>(`${WEB}/uploads/shelved`, body),
|
||||||
|
};
|
||||||
58
src/auth/roles.test.ts
Normal file
58
src/auth/roles.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Which workspace a roleid lands in — and the one that has been mislabelled on
|
||||||
|
* every shop since the platform started.
|
||||||
|
*
|
||||||
|
* `app_roles` calls roleid 1 "Super admin", and tenant onboarding wrote 1 for a
|
||||||
|
* merchant's own administrator. So every shop's Users & access screen listed
|
||||||
|
* its owner as a platform operator. It never WAS one — platform access is
|
||||||
|
* `app_users.issuperadmin`, a separate column checked first — but the label is
|
||||||
|
* the sort of thing somebody eventually acts on.
|
||||||
|
*
|
||||||
|
* New tenants get roleid 3 ("Admin"). Existing ones keep 1, and must keep
|
||||||
|
* working: nine shops were provisioned with it.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { resolveRole } from './roles';
|
||||||
|
|
||||||
|
test('platform access comes from issuperadmin, never from a roleid', () => {
|
||||||
|
assert.equal(resolveRole({ roleid: 0, issuperadmin: true }), 'nearle-admin');
|
||||||
|
// The point of the whole fix: roleid 1 is a merchant, not a platform operator.
|
||||||
|
assert.equal(resolveRole({ roleid: 1, issuperadmin: false }), 'store-admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a merchant administrator reaches the Store Admin workspace', () => {
|
||||||
|
// 3 is what new tenants get; 1 is what every existing tenant has.
|
||||||
|
assert.equal(resolveRole({ roleid: 3, issuperadmin: false }), 'store-admin');
|
||||||
|
assert.equal(resolveRole({ roleid: 1, issuperadmin: false }), 'store-admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Existing merchants must not be locked out. Nine shops were provisioned with
|
||||||
|
roleid 1 before this changed, and dropping it from the store-admin set would
|
||||||
|
shut every one of their owners out of their own console.
|
||||||
|
*/
|
||||||
|
test('roleid 1 keeps working, so no existing merchant is locked out', () => {
|
||||||
|
assert.notEqual(resolveRole({ roleid: 1, issuperadmin: false }), 'store-manager');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a manager is pinned to one shop', () => {
|
||||||
|
// 4 is "Manager" in app_roles, and what the old console gave rmartuser.
|
||||||
|
assert.equal(resolveRole({ roleid: 4, issuperadmin: false }), 'store-manager');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The auto-spawned branch login carries roleid 0 — Go's zero value, and the
|
||||||
|
// branch-user role. It must land in the shop workspace, not the merchant's.
|
||||||
|
test('the branch login lands in the store workspace', () => {
|
||||||
|
assert.equal(resolveRole({ roleid: 0, issuperadmin: false }), 'store-manager');
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Till accounts must never reach a back-office workspace. They are excluded in the
|
||||||
|
backend's queries too — a cashier is "not found" rather than "refused" — but a
|
||||||
|
roleid arriving from anywhere else must not resolve upward.
|
||||||
|
*/
|
||||||
|
test('till roles never resolve to a merchant workspace', () => {
|
||||||
|
assert.equal(resolveRole({ roleid: 7, issuperadmin: false }), 'store-manager');
|
||||||
|
assert.equal(resolveRole({ roleid: 8, issuperadmin: false }), 'store-manager');
|
||||||
|
});
|
||||||
@@ -34,6 +34,19 @@ export interface SessionUser {
|
|||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid: number;
|
||||||
issuperadmin: boolean;
|
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.
|
* sub-path is absorbed there and never reaches the global one.
|
||||||
*/
|
*/
|
||||||
export const HOME_ROUTE: Record<ConsoleRole, string> = {
|
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-admin': '/admin/console',
|
||||||
'store-manager': '/store/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.
|
* Sign-in and session persistence.
|
||||||
*
|
*
|
||||||
* There is no token to hold. `TenantWebLogin` returns the user record and
|
* The session is the user record plus, now, a signed token. Until Fiesta grew
|
||||||
* nothing else, so the session IS that record. It is kept in sessionStorage
|
* `middleware.WebAuth` there was no token to hold: login returned the record and
|
||||||
* rather than localStorage: a shared back-office machine should not stay signed
|
* nothing else, the console asserted its own `tenantid` on every request, and
|
||||||
* in after the browser closes, and there is no server-side session to revoke.
|
* 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 { api, WEB } from '@/api/client';
|
||||||
import type { FiestaUser } from '@/api/types';
|
import type { FiestaUser } from '@/api/types';
|
||||||
import { toSessionUser, type SessionUser } from './roles';
|
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 {
|
export class PasswordSetupRequiredError extends Error {
|
||||||
readonly userid: number;
|
constructor() {
|
||||||
constructor(userid: number) {
|
|
||||||
super('This account needs a password before it can sign in.');
|
super('This account needs a password before it can sign in.');
|
||||||
this.name = 'PasswordSetupRequiredError';
|
this.name = 'PasswordSetupRequiredError';
|
||||||
this.userid = userid;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,23 +87,61 @@ const CONFIG_ID = 1;
|
|||||||
export async function login(email: string, password: string): Promise<SessionUser> {
|
export async function login(email: string, password: string): Promise<SessionUser> {
|
||||||
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
|
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
|
||||||
|
|
||||||
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
|
const envelope = await api.envelope<FiestaUser & { setup?: boolean }>(`${WEB}/users/applogin`, {
|
||||||
`${WEB}/users/applogin`,
|
method: 'POST',
|
||||||
{ method: 'POST', body },
|
body,
|
||||||
);
|
});
|
||||||
|
|
||||||
// A brand-new account — `createtenantlocation` spawns branch logins with an
|
// A brand-new account — `createtenantlocation` spawns branch logins with an
|
||||||
// empty password — answers `status: true` with a 409 and the userid to set
|
// empty password — answers `status: true` with a 409. Not a failure: the
|
||||||
// one against. It is not a failure, it is the first step.
|
// account is real and is waiting for its invitation to be used.
|
||||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
if (envelope.code === 409 && envelope.details?.setup === true) {
|
||||||
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
|
throw new PasswordSetupRequiredError();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envelope.status !== true || !envelope.details) {
|
if (envelope.status !== true || !envelope.details) {
|
||||||
throw new Error(loginMessage(envelope.code, envelope.message));
|
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);
|
persist(session);
|
||||||
return 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")
|
* 409 + status false → no such account ("Invalid Email")
|
||||||
* 403 → account deactivated
|
* 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")
|
* 401 + status true → exists, has a password ("Password is required")
|
||||||
*
|
*
|
||||||
* The last one is the whole trick: a password-less attempt against a real
|
* 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
|
* password, so the probe reveals nothing that was not already available with
|
||||||
* one more field filled in.
|
* 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> {
|
export async function checkAccount(email: string): Promise<AccountCheck> {
|
||||||
const envelope = await api.envelope<{ setup?: boolean; userid?: number }>(
|
const envelope = await api.envelope<{ setup?: boolean }>(`${WEB}/users/applogin`, {
|
||||||
`${WEB}/users/applogin`,
|
method: 'POST',
|
||||||
{ method: 'POST', body: { authname: email.trim(), configid: CONFIG_ID } },
|
body: { authname: email.trim(), configid: CONFIG_ID },
|
||||||
);
|
});
|
||||||
|
|
||||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
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
|
// "Password is required" — the account is real and has one. Exactly what we
|
||||||
// wanted to learn, arriving as a refusal.
|
// 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.
|
* Sets the password on an account that has never had one.
|
||||||
*
|
*
|
||||||
* `PUT /users/update` doubles as the password call. There is no dedicated
|
* `POST /users/setpassword`, which is public — it has to be. This runs when
|
||||||
* endpoint and no reset flow — the controller says so in as many words
|
* nobody is signed in and cannot be: the account has no password yet, so there
|
||||||
* (`userController.go:145`): "this endpoint also doubles as the
|
* is no way to obtain a session first.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* This is reachable only with the `userid` that `applogin` just handed back for
|
* It used to call `PUT /users/update`, which doubles as a password write but
|
||||||
* an account it confirmed has an empty password. It is not a "change my
|
* sits behind the session guard. Once `WEB_AUTH_REQUIRED` began defaulting on,
|
||||||
* password" call and must not be wired up as one — nothing here verifies the
|
* that returned "a session token is required; sign in again" to somebody who
|
||||||
* old password, because there is no old password.
|
* 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
|
* 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
|
* console can fix.
|
||||||
* emailed setup link.
|
|
||||||
*/
|
*/
|
||||||
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) {
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
|
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 {
|
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 {
|
export function restore(): SessionUser | null {
|
||||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as SessionUser;
|
const parsed = JSON.parse(raw) as SessionUser;
|
||||||
// A stored blob is only as trustworthy as the tab it came from; a shape
|
// 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.
|
// check keeps a corrupted value from crashing the shell on boot.
|
||||||
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
|
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;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -202,5 +331,5 @@ export function restore(): SessionUser | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clear(): void {
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { isStaleChunkError } from '@/lib/staleChunk';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The last line before a white page.
|
* The last line before a white page.
|
||||||
@@ -45,12 +46,28 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
this.setState({ error: null });
|
this.setState({ error: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private reload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const { error } = this.state;
|
const { error } = this.state;
|
||||||
if (!error) return this.props.children;
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
const area = this.props.area ?? 'this screen';
|
const area = this.props.area ?? 'this screen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page whose code never arrived cannot be re-rendered into existence.
|
||||||
|
*
|
||||||
|
* "Try again" clears the error and renders the same `lazy()` component,
|
||||||
|
* which requests the same missing file and throws the same error — the
|
||||||
|
* button looked like a recovery and was a loop. `lib/staleChunk.ts` already
|
||||||
|
* reloads once on its own; landing here means that reload has happened and
|
||||||
|
* not helped, or was suppressed to avoid a boot loop, so the honest offer
|
||||||
|
* is a reload the person chooses and a message that names the cause.
|
||||||
|
*/
|
||||||
|
const isStale = isStaleChunkError(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -58,18 +75,30 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
margin: '48px auto',
|
margin: '48px auto',
|
||||||
maxWidth: 620,
|
maxWidth: 620,
|
||||||
padding: '28px 32px',
|
padding: '28px 32px',
|
||||||
borderRadius: 16,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: '1px solid #F1D3D3',
|
border: '1px solid #F1D3D3',
|
||||||
background: '#FFFBFB',
|
background: '#FFFBFB',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
|
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
|
||||||
Something on {area} failed to render
|
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
|
||||||
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.
|
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
|
||||||
|
? 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>
|
</p>
|
||||||
<pre
|
<pre
|
||||||
style={{
|
style={{
|
||||||
@@ -88,10 +117,13 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
</pre>
|
</pre>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={this.reset}
|
onClick={isStale ? this.reload : this.reset}
|
||||||
style={{
|
style={{
|
||||||
padding: '9px 18px',
|
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',
|
border: '1px solid #D9C0C0',
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -99,7 +131,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Try again
|
{isStale ? 'Reload the page' : 'Try again'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,107 +1,89 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import './kpiCard.css';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
/**
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
* 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';
|
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 {
|
export interface KpiCardProps {
|
||||||
/** Small-caps label. Say what it is, not what it means. */
|
/** Small-caps label. Say what it is, not what it means. */
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
/**
|
/**
|
||||||
* The sub-pill. This is where the number gets its meaning — "1 of 84 orders"
|
* The line under the figure — what the number is made of, or what it is a
|
||||||
* says something "1.2%" does not.
|
* share of. This is where a KPI gets its meaning; see the note above.
|
||||||
*/
|
*/
|
||||||
note?: string;
|
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;
|
tone?: KpiTone;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Accepted and ignored.
|
* Accepted and ignored.
|
||||||
*
|
*
|
||||||
* The underline bar this used to drive was removed from the tile. Six call
|
* The 2px underline bar this drove was removed from the tile deliberately —
|
||||||
* sites still pass it, so the prop stays declared to keep them compiling —
|
* a proportion bar under five tiles turned the strip into a chart. Six call
|
||||||
* it is not read. Either drop it from the callers or restore the bar; right
|
* sites still pass it, so the prop stays declared to keep them compiling.
|
||||||
* now it is neither, and this comment is here so that is visible.
|
* Drop it from those callers and this can go.
|
||||||
*/
|
*/
|
||||||
fill?: number;
|
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) {
|
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
|
||||||
const color = TONE_COLOR[tone];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding={0} elevation="low">
|
<div className="kpi-card" data-tone={tone}>
|
||||||
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
|
{/* Label and icon share the top row; the figure gets the full width of
|
||||||
{/* Icon first, then the label; the pill sits opposite it. Both are
|
the card underneath them.
|
||||||
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
|
The icon used to sit in a column of its own to the LEFT, and at the
|
||||||
squeezes it) does not push that tile's value below its neighbours'. */}
|
width these tiles actually get — five across a 1,224px column, less
|
||||||
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
|
340px whenever Nearle Buddy is open — a 40px tile plus its gutter
|
||||||
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
|
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 ? (
|
{icon ? (
|
||||||
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
|
<span className="kpi-card-icon" aria-hidden>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<Text
|
</div>
|
||||||
type="label"
|
|
||||||
size="xsm"
|
|
||||||
color="secondary"
|
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{note ? (
|
<span className="kpi-card-value">{value}</span>
|
||||||
<span
|
{note ? <span className="kpi-card-note">{note}</span> : null}
|
||||||
style={{
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { StickyRow } from './StickyRow';
|
||||||
|
|
||||||
export interface PageHeaderProps {
|
export interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
|
|
||||||
count?: string;
|
|
||||||
/** One line of context. */
|
|
||||||
description?: string;
|
|
||||||
/** Right-aligned actions, wrapping. */
|
/** Right-aligned actions, wrapping. */
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
/** An optional tabs row directly under the header rule. */
|
/** An optional tabs row directly under the header rule. */
|
||||||
tabs?: ReactNode;
|
tabs?: ReactNode;
|
||||||
isLive?: boolean;
|
/**
|
||||||
|
* Put the tabs on the SAME line as the actions — tabs left, actions right —
|
||||||
|
* instead of on a row of their own beneath.
|
||||||
|
*
|
||||||
|
* Worth having now that the page titles are gone: with nothing above them the
|
||||||
|
* tabs and a lone button sat on two nearly empty lines, and pulling them onto
|
||||||
|
* one gives the page back a row without crowding anything.
|
||||||
|
*/
|
||||||
|
isTabsInline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page frame header, built to KROW's `AdminPage` spec.
|
* The page frame header, built to KROW's `AdminPage` spec.
|
||||||
*
|
*
|
||||||
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
|
* Actions right-aligned and wrapping, with an optional tabs row beneath.
|
||||||
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
|
* Everything the page stacks below sits on a 24px rhythm.
|
||||||
* rhythm.
|
*
|
||||||
|
* The visible title, count, Live pill and description are gone from every page
|
||||||
|
* in all three workspaces. They restated what the chrome already says — the top
|
||||||
|
* bar names the section, the branch picker gives the count — and the
|
||||||
|
* description was a line of prose above data that nobody reads twice. The title
|
||||||
|
* survives as a screen-reader-only h1; see the note at the call.
|
||||||
*
|
*
|
||||||
* There was a hairline rule under all of this, with 16px of padding above it
|
* There was a hairline rule under all of this, with 16px of padding above it
|
||||||
* and another 12px below before the tabs — 28px of nothing plus a line, on
|
* and another 12px below before the tabs — 28px of nothing plus a line, on
|
||||||
@@ -30,10 +40,44 @@ export interface PageHeaderProps {
|
|||||||
*
|
*
|
||||||
* The tabs' spacing lives here rather than at each call site, so the five pages
|
* 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.
|
* 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, count, description, actions, tabs, isLive }: PageHeaderProps) {
|
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
|
||||||
|
const hasRow = Boolean(actions || (isTabsInline && tabs));
|
||||||
|
const hasTabsRow = Boolean(tabs && !isTabsInline);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/*
|
||||||
|
The title is still here, and still read aloud — it is just not drawn.
|
||||||
|
|
||||||
|
Removing it visually was the ask; removing it outright would leave every
|
||||||
|
page in the console with no h1, no document outline and nothing for a
|
||||||
|
screen reader to announce on navigation. `sr-only` is absolutely
|
||||||
|
positioned, so it is out of flow and costs no layout.
|
||||||
|
*/}
|
||||||
|
<h1 className="sr-only">{title}</h1>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
No row at all when there is nothing to put in it.
|
||||||
|
|
||||||
|
It used to render an empty <header> regardless. At zero height that
|
||||||
|
looks free, but it is still a flex child, so it collected the column's
|
||||||
|
12px gap and pushed everything down — a page with no actions sat 36px
|
||||||
|
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 || hasTabsRow ? (
|
||||||
|
<StickyRow>
|
||||||
|
{hasRow ? (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -43,67 +87,9 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
|||||||
gap: 16,
|
gap: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
{/* Left of the row when inline, so the tabs start at the page's
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
left edge and the actions stay on the right. */}
|
||||||
<h1
|
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
|
||||||
className="page-title"
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontFamily: 'var(--font-display)',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
color: 'var(--color-ink-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{count ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'var(--color-ink-4)',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLive ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-brand-tint)',
|
|
||||||
color: 'var(--color-brand)',
|
|
||||||
padding: '2px 10px',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-brand)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{description ? (
|
|
||||||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actions ? (
|
{actions ? (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -111,7 +97,11 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
|
) : 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',
|
alignItems: 'center',
|
||||||
gap: 14,
|
gap: 14,
|
||||||
padding: '14px 16px',
|
padding: '14px 16px',
|
||||||
borderRadius: 14,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: '1px solid var(--color-line)',
|
border: 'var(--card-border)',
|
||||||
background: 'var(--color-surface-subtle)',
|
background: 'var(--color-surface-subtle)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -153,7 +153,7 @@ export function SheetDropzone({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: '16px 20px',
|
padding: '16px 20px',
|
||||||
borderRadius: 13,
|
borderRadius: 'var(--card-radius)',
|
||||||
border: `1.5px dashed ${
|
border: `1.5px dashed ${
|
||||||
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/TablePager.tsx
Normal file
49
src/components/TablePager.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
|
import type { Paged } from './usePaged';
|
||||||
|
import { PAGE_SIZES } from './usePaged';
|
||||||
|
import './tablePager.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pager that sits under a table.
|
||||||
|
*
|
||||||
|
* One component so every table in the console counts, labels and behaves the
|
||||||
|
* same way, rather than each page inventing its own row of buttons.
|
||||||
|
*
|
||||||
|
* ── It hides itself ─────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Nothing renders while everything fits on one page. That is what makes it safe
|
||||||
|
* to put under EVERY table, including the ones that usually hold four rows: a
|
||||||
|
* pager reading "1–4 of 4" next to a dead prev/next pair is noise, and noise
|
||||||
|
* under every table is worse than no pager at all. It appears exactly when it
|
||||||
|
* has something to offer.
|
||||||
|
*
|
||||||
|
* `variant="count"` — "21–40 of 96" — rather than a strip of page numbers.
|
||||||
|
* With 25 rows a page a busy day is four pages, and the number an operator
|
||||||
|
* actually wants is how much is left, not which of four buttons is lit.
|
||||||
|
*/
|
||||||
|
export function TablePager({
|
||||||
|
paged,
|
||||||
|
label = 'rows',
|
||||||
|
}: {
|
||||||
|
paged: Paged<unknown>;
|
||||||
|
/** What is being counted, for the screen-reader label: "orders", "products". */
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
if (paged.totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-pager">
|
||||||
|
<Pagination
|
||||||
|
page={paged.page}
|
||||||
|
onChange={paged.setPage}
|
||||||
|
totalItems={paged.total}
|
||||||
|
pageSize={paged.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZES}
|
||||||
|
onPageSizeChange={paged.setPageSize}
|
||||||
|
variant="count"
|
||||||
|
size="sm"
|
||||||
|
label={`Page through ${label}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
|
|||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { ROLE_LABEL } from '@/auth/roles';
|
import { ROLE_LABEL } from '@/auth/roles';
|
||||||
import { AssistantPanel } from './AssistantPanel';
|
import { AssistantPanel } from './AssistantPanel';
|
||||||
|
import { DateScopePicker } from './DateScope';
|
||||||
|
|
||||||
export interface NavEntry {
|
export interface NavEntry {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -59,6 +60,15 @@ export interface AppShellProps {
|
|||||||
* closed.
|
* closed.
|
||||||
*/
|
*/
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
|
/**
|
||||||
|
* A full-width strip between the header and the page.
|
||||||
|
*
|
||||||
|
* The setup walkthrough lives here. It has to sit in the shell rather than
|
||||||
|
* on a page because it crosses several: one step is on Profile, the next on
|
||||||
|
* Users, the next on Inventory — anything page-local would vanish the moment
|
||||||
|
* somebody followed it.
|
||||||
|
*/
|
||||||
|
banner?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +94,7 @@ export function AppShell({
|
|||||||
scopeControl,
|
scopeControl,
|
||||||
manageItems,
|
manageItems,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
banner,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -94,6 +105,29 @@ export function AppShell({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
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
|
// 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
|
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
||||||
// sits over the page rather than beside it.
|
// sits over the page rather than beside it.
|
||||||
@@ -123,14 +157,26 @@ export function AppShell({
|
|||||||
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
|
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
|
||||||
on exactly the same left edge as the page title below them at every
|
on exactly the same left edge as the page title below them at every
|
||||||
width, including a 2560px monitor where the body is centred. */}
|
width, including a 2560px monitor where the body is centred. */}
|
||||||
|
{/*
|
||||||
|
Opaque, with no backdrop blur.
|
||||||
|
|
||||||
|
The blur cost more than it bought the moment a popover moved into this
|
||||||
|
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
|
||||||
|
`position: fixed` descendant, and the design system's popovers are fixed
|
||||||
|
and CSS-anchor-positioned — so the date picker's calendar rendered
|
||||||
|
inside the header at 0×0 and could not be opened at all. The same
|
||||||
|
control worked perfectly two pixels lower, on the page.
|
||||||
|
|
||||||
|
A solid background is the fix rather than a hack around it: the bar sits
|
||||||
|
on a near-white page, so at 85% opacity plus blur it was already almost
|
||||||
|
opaque, and nothing here reads differently for losing it.
|
||||||
|
*/}
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 40,
|
zIndex: 40,
|
||||||
background: 'rgba(255,255,255,.85)',
|
background: 'var(--color-surface)',
|
||||||
backdropFilter: 'blur(24px)',
|
|
||||||
WebkitBackdropFilter: 'blur(24px)',
|
|
||||||
borderBottom: '1px solid var(--color-line)',
|
borderBottom: '1px solid var(--color-line)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -237,6 +283,13 @@ export function AppShell({
|
|||||||
the catalogue are real and stay. A control that looks like it
|
the catalogue are real and stay. A control that looks like it
|
||||||
works costs more trust than a missing one. */}
|
works costs more trust than a missing one. */}
|
||||||
|
|
||||||
|
{/* The date filter, beside the profile and common to every page.
|
||||||
|
|
||||||
|
Rendered here rather than by each page so the two questions the
|
||||||
|
console asks — which shop, and when — are both answered in the
|
||||||
|
chrome, and so the answer survives navigation. */}
|
||||||
|
<DateScopePicker />
|
||||||
|
|
||||||
{headerActions}
|
{headerActions}
|
||||||
|
|
||||||
{/* No notification bell. It was labelled "2 unread" with the dot
|
{/* No notification bell. It was labelled "2 unread" with the dot
|
||||||
@@ -402,6 +455,8 @@ export function AppShell({
|
|||||||
|
|
||||||
{/* Body: a column on a phone so the assistant stacks under the page, a
|
{/* Body: a column on a phone so the assistant stacks under the page, a
|
||||||
row from md where it becomes a side column. */}
|
row from md where it becomes a side column. */}
|
||||||
|
{banner}
|
||||||
|
|
||||||
<div className="admin-body app-gutter">
|
<div className="admin-body app-gutter">
|
||||||
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
|
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
|
||||||
{/* Scoped to the page, not the shell: a page that throws should leave
|
{/* Scoped to the page, not the shell: a page that throws should leave
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
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 { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DEFAULT_WIDTH,
|
DEFAULT_WIDTH,
|
||||||
@@ -9,114 +18,313 @@ import {
|
|||||||
useAssistantWidth,
|
useAssistantWidth,
|
||||||
} from './assistantWidth';
|
} from './assistantWidth';
|
||||||
|
|
||||||
/** Per-route context, so the panel knows which page it is sitting beside. */
|
/** One question and what came back. `answer` and `error` are exclusive. */
|
||||||
const CONTEXT: Record<
|
interface Exchange {
|
||||||
string,
|
question: string;
|
||||||
{ page: string; title: string; greeting: string; reading: string; prompts: string[] }
|
answer?: AssistantAnswer;
|
||||||
> = {
|
error?: string;
|
||||||
'/nearle/stores': {
|
/**
|
||||||
page: 'Stores',
|
* What became of a change Buddy proposed.
|
||||||
title: 'Nearle Buddy',
|
*
|
||||||
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
|
* `undefined` means the card is still on screen waiting. Once decided the
|
||||||
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
|
* card is replaced by its outcome and cannot be pressed again — a card that
|
||||||
prompts: [
|
* stayed live after approval is a second write waiting to happen.
|
||||||
'Which tenants have no branches?',
|
*/
|
||||||
'Who onboarded most recently?',
|
decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string };
|
||||||
'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?',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ── 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',
|
* What the composer says when it cannot be used, and why.
|
||||||
title: 'Across your branches',
|
*
|
||||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
* Three different states with three different sentences. "Not connected yet"
|
||||||
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
|
* for everything would be true and useless: a person whose deployment has no
|
||||||
prompts: [
|
* model can do nothing, but a person on the Inventory page can move to Sales
|
||||||
'Which branch is underperforming?',
|
* and get an answer today.
|
||||||
'Any tills not syncing?',
|
*/
|
||||||
'What needs my approval?',
|
export function composerHint(
|
||||||
'Where is stock running out?',
|
available: boolean | null,
|
||||||
],
|
hasAgent: boolean,
|
||||||
},
|
isAsking: boolean,
|
||||||
'/admin/sales': {
|
): string {
|
||||||
page: 'Sales',
|
if (isAsking) return 'Thinking…';
|
||||||
title: 'Orders and deliveries',
|
if (available === null) return 'Checking…';
|
||||||
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
|
if (available === false) return 'Not connected yet';
|
||||||
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
|
if (!hasAgent) return 'No assistant for this page yet';
|
||||||
prompts: [
|
return 'Ask about this page';
|
||||||
'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?',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const FALLBACK = {
|
/**
|
||||||
page: 'Console',
|
* The line under the composer, or nothing.
|
||||||
title: 'Good afternoon',
|
*
|
||||||
greeting: 'Ask about anything on this page.',
|
* This used to be one hardcoded sentence — "Not connected yet — there is no
|
||||||
reading: 'Would cover this page.',
|
* assistant service behind this panel." — written before the panel had an API
|
||||||
prompts: ['What needs attention?', 'Summarise this page'],
|
* 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.
|
* Nearle Buddy, as a layout column.
|
||||||
@@ -142,11 +350,151 @@ export function AssistantPanel({
|
|||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
const { width, setWidth, reset } = useAssistantWidth();
|
const { width, setWidth, reset } = useAssistantWidth();
|
||||||
const [isDragging, setDragging] = useState(false);
|
const [isDragging, setDragging] = useState(false);
|
||||||
|
|
||||||
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
|
const { key, context: routeContext } = matchAssistantRoute(pathname);
|
||||||
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
|
|
||||||
|
/**
|
||||||
|
* The heading follows the branch in view.
|
||||||
|
*
|
||||||
|
* A panel headed "Across your branches" sitting above a board showing one
|
||||||
|
* shop is a promise about scope that the page has already broken. Only the
|
||||||
|
* title moves; everything else about Buddy is untouched.
|
||||||
|
*/
|
||||||
|
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),
|
* Three widths, in priority order: stacked (the phone layout owns it),
|
||||||
@@ -246,6 +594,35 @@ export function AssistantPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
{/* Greeting — top-aligned, because it is the first thing in a
|
||||||
conversation, not a splash screen. */}
|
conversation, not a splash screen. */}
|
||||||
<div>
|
<div>
|
||||||
@@ -285,25 +662,58 @@ export function AssistantPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
|
||||||
is a bug no amount of fade masking fixes. */}
|
is a bug no amount of fade masking fixes. */}
|
||||||
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
{context.prompts.map((prompt) => (
|
{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>
|
</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
|
This composer used to accept text and enable a brand-purple send
|
||||||
button the moment you typed — then swallow the submit. There is no
|
button the moment you typed, then swallow the submit, because no
|
||||||
assistant endpoint anywhere in `src/api`. A disabled field says the
|
assistant endpoint existed. It was disabled rather than left to lie.
|
||||||
same thing honestly and costs nobody a message they thought they
|
It is enabled here exactly when a model is configured AND this page
|
||||||
sent. */}
|
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
|
<form
|
||||||
onSubmit={(event) => event.preventDefault()}
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void send(draft);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
@@ -330,9 +740,18 @@ export function AssistantPanel({
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
disabled
|
onKeyDown={(event) => {
|
||||||
placeholder="Not connected yet"
|
// Enter sends, shift+Enter writes a second line. The composer is
|
||||||
aria-label="Ask Nearle Buddy — not connected yet"
|
// 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={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -351,18 +770,19 @@ export function AssistantPanel({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
disabled
|
disabled={!canAsk || draft.trim() === ''}
|
||||||
style={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
border: 0,
|
border: 0,
|
||||||
flex: 'none',
|
flex: 'none',
|
||||||
background: 'var(--color-surface-sunken)',
|
background:
|
||||||
color: 'var(--color-ink-4)',
|
canAsk && draft.trim() !== '' ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
||||||
|
color: canAsk && draft.trim() !== '' ? '#fff' : 'var(--color-ink-4)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
cursor: 'default',
|
cursor: canAsk && draft.trim() !== '' ? 'pointer' : 'default',
|
||||||
transition: 'background .2s',
|
transition: 'background .2s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -370,6 +790,7 @@ export function AssistantPanel({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{composerNote(available, Boolean(agent)) ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@@ -379,8 +800,9 @@ export function AssistantPanel({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Not connected yet — there is no assistant service behind this panel.
|
{composerNote(available, Boolean(agent))}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
131
src/components/shell/DateScope.tsx
Normal file
131
src/components/shell/DateScope.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import type { DateRange } from '@/api/insights';
|
||||||
|
import {
|
||||||
|
DateRangePicker,
|
||||||
|
presetRange,
|
||||||
|
type RangePreset,
|
||||||
|
} from '@/features/store-admin/DateRangePicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console's date filter, held once for the whole workspace.
|
||||||
|
*
|
||||||
|
* It sits in the top bar beside the profile, not on the page, and every page
|
||||||
|
* reads it from here — the same relationship `BranchScope` already has to the
|
||||||
|
* branch picker beside the logo. The two scopes now work the same way: the two
|
||||||
|
* questions every page is asked, "which shop" and "when", are answered once in
|
||||||
|
* the chrome rather than re-answered on each screen.
|
||||||
|
*
|
||||||
|
* ── What this changes about the pages ───────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The range survives navigation. Setting March on Sales and clicking through to
|
||||||
|
* Reports shows March, which is what somebody looking into a month actually
|
||||||
|
* wants and is the whole reason for lifting it. It also means the live boards —
|
||||||
|
* Console, Counters, Terminals — no longer force themselves back to today; they
|
||||||
|
* follow the shared range like everything else. That is the trade the move
|
||||||
|
* makes, and it is the right one, but it IS a change: those three used to be
|
||||||
|
* pinned to the day whatever else you had chosen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 the USER has narrowed the page, not merely that a window exists. */
|
||||||
|
isFiltered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
||||||
|
/* 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 hasChoice = Boolean(chosen.fromdate || chosen.todate);
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
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);
|
||||||
|
setChosen(nextRange);
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
setPreset('custom');
|
||||||
|
setChosen({});
|
||||||
|
},
|
||||||
|
isFiltered: hasChoice,
|
||||||
|
};
|
||||||
|
}, [preset, chosen]);
|
||||||
|
|
||||||
|
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared range.
|
||||||
|
*
|
||||||
|
* Throws outside the provider rather than inventing a local range: a page that
|
||||||
|
* silently filtered on its own dates while the bar showed something else would
|
||||||
|
* be the exact confusion this exists to remove.
|
||||||
|
*/
|
||||||
|
export function useDateScope(): DateScopeValue {
|
||||||
|
const value = useContext(DateScopeContext);
|
||||||
|
if (!value) throw new Error('useDateScope must be used inside a DateScopeProvider');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The control itself. Rendered once, in the top bar. */
|
||||||
|
export function DateScopePicker() {
|
||||||
|
const dates = useDateScope();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
21
src/components/shell/assistantScope.ts
Normal file
21
src/components/shell/assistantScope.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Nearle Buddy is currently answering about.
|
||||||
|
*
|
||||||
|
* The panel names its own scope in its heading — "Across your branches" — and
|
||||||
|
* that heading is a promise about which data an answer would draw on. When an
|
||||||
|
* admin narrows to one branch, or a store user opens the console at all, the
|
||||||
|
* promise is wrong: the board beneath says R mart and the panel above still
|
||||||
|
* says every branch.
|
||||||
|
*
|
||||||
|
* So the SCOPE is published here and the panel reads it. Nothing else about
|
||||||
|
* Buddy changes — not its width, its controls, its prompts or its placement.
|
||||||
|
* A page that has no branch scope publishes nothing and the panel keeps the
|
||||||
|
* per-route wording it has always used.
|
||||||
|
*/
|
||||||
|
export const AssistantScopeContext = createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useAssistantScope(): string | undefined {
|
||||||
|
return useContext(AssistantScopeContext);
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
24
src/components/tablePager.css
Normal file
24
src/components/tablePager.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/* ══ The pager under a table ═══════════════════════════════════════════════
|
||||||
|
Sits inside the table's own card, below the last row, so it reads as part of
|
||||||
|
the table rather than as a separate control floating beneath it. */
|
||||||
|
|
||||||
|
.table-pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
/* The rule is the seam between the last row and the controls. Rows already
|
||||||
|
draw their own bottom border, so this only shows where the table ends. */
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a narrow window the count and the buttons stop fitting side by side.
|
||||||
|
Centred rather than left-aligned once wrapped, so the two lines read as one
|
||||||
|
block instead of a ragged edge. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.table-pager {
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
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%);
|
||||||
|
}
|
||||||
104
src/components/usePaged.test.ts
Normal file
104
src/components/usePaged.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from './usePaged';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `usePaged` is a hook, and there is no React renderer in this suite — the repo
|
||||||
|
* runs `tsx --test`, not jsdom. So the arithmetic it depends on is written here
|
||||||
|
* as the pure function the hook applies, and asserted directly.
|
||||||
|
*
|
||||||
|
* That is worth doing rather than skipping: every bug this hook exists to
|
||||||
|
* prevent is an arithmetic one at a boundary — an empty list, a list that
|
||||||
|
* shrinks under a page, a page size that divides exactly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Slice {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
rows: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the derivation in `usePaged`, including the clamp. */
|
||||||
|
function slice(rows: readonly number[], requestedPage: number, pageSize: number): Slice {
|
||||||
|
const total = rows.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const page = Math.min(requestedPage, totalPages);
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
from: total === 0 ? 0 : (page - 1) * pageSize + 1,
|
||||||
|
to: Math.min(page * pageSize, total),
|
||||||
|
rows: rows.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const upTo = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
test('a full first page', () => {
|
||||||
|
const out = slice(upTo(96), 1, 25);
|
||||||
|
assert.equal(out.totalPages, 4);
|
||||||
|
assert.deepEqual([out.from, out.to], [1, 25]);
|
||||||
|
assert.equal(out.rows[0], 1);
|
||||||
|
assert.equal(out.rows.at(-1), 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a middle page counts from the right place', () => {
|
||||||
|
// The off-by-one everybody writes at least once.
|
||||||
|
const out = slice(upTo(96), 3, 25);
|
||||||
|
assert.deepEqual([out.from, out.to], [51, 75]);
|
||||||
|
assert.equal(out.rows[0], 51);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last page is short, and says so', () => {
|
||||||
|
const out = slice(upTo(96), 4, 25);
|
||||||
|
assert.deepEqual([out.from, out.to], [76, 96]);
|
||||||
|
assert.equal(out.rows.length, 21);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an exact multiple does not produce a trailing empty page', () => {
|
||||||
|
// 100 rows at 25 is four pages, not five.
|
||||||
|
assert.equal(slice(upTo(100), 1, 25).totalPages, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no rows is one page reading 0–0, not zero pages', () => {
|
||||||
|
/*
|
||||||
|
`Math.ceil(0 / 25)` is 0, and a totalPages of 0 makes the pager render "page 1
|
||||||
|
of 0" and every control dead. One empty page is the honest shape.
|
||||||
|
*/
|
||||||
|
const out = slice([], 1, 25);
|
||||||
|
assert.equal(out.totalPages, 1);
|
||||||
|
assert.deepEqual([out.from, out.to], [0, 0]);
|
||||||
|
assert.deepEqual(out.rows, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a list that shrinks under you clamps instead of going blank', () => {
|
||||||
|
/*
|
||||||
|
The bug this hook exists for. Sitting on page 4, somebody narrows the filter
|
||||||
|
to nine results. Slicing at the requested page would read rows 76–100 of a
|
||||||
|
nine-row list and render an empty table with the controls saying page 4 —
|
||||||
|
which looks exactly like the data vanished.
|
||||||
|
*/
|
||||||
|
const out = slice(upTo(9), 4, 25);
|
||||||
|
assert.equal(out.page, 1, 'clamped to the last page that exists');
|
||||||
|
assert.equal(out.rows.length, 9);
|
||||||
|
assert.deepEqual([out.from, out.to], [1, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamping lands on the LAST page, not always the first', () => {
|
||||||
|
// 60 rows at 25 is three pages; from page 9 you belong on 3, not on 1.
|
||||||
|
const out = slice(upTo(60), 9, 25);
|
||||||
|
assert.equal(out.page, 3);
|
||||||
|
assert.deepEqual([out.from, out.to], [51, 60]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every offered page size divides the work sensibly', () => {
|
||||||
|
// Guards the constants themselves: a 0 or a negative here would make
|
||||||
|
// totalPages Infinity and hang the pager.
|
||||||
|
for (const size of PAGE_SIZES) {
|
||||||
|
assert.ok(size > 0 && Number.isInteger(size), `${size} is a usable page size`);
|
||||||
|
assert.equal(slice(upTo(100), 1, size).rows.length, Math.min(size, 100));
|
||||||
|
}
|
||||||
|
assert.ok(PAGE_SIZES.includes(DEFAULT_PAGE_SIZE), 'the default is one of the choices');
|
||||||
|
});
|
||||||
131
src/components/usePaged.ts
Normal file
131
src/components/usePaged.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paging for a table, over rows already in hand.
|
||||||
|
*
|
||||||
|
* ── Why client-side ─────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* Fiesta pages properly — `pageno` genuinely shifts the window, verified
|
||||||
|
* against `getorders`. What it does NOT return is a total: the envelope carries
|
||||||
|
* `code`, `details`, `message`, `status` and nothing else. So a server-paged
|
||||||
|
* table could offer next/prev and never honestly say "of 12 pages", and could
|
||||||
|
* not tell a last page from an empty one until it fetched it.
|
||||||
|
*
|
||||||
|
* Every list here already fetches a bounded window (200 rows, 500 for the
|
||||||
|
* customer book) and renders all of it. Paging that window client-side gives a
|
||||||
|
* real total, real page numbers, instant page turns, and works identically for
|
||||||
|
* the tables that have no server paging at all — grouped dispatch stops,
|
||||||
|
* reports, anything derived. When a table outgrows its fetch window the answer
|
||||||
|
* is to raise the window or move that ONE table to cursor paging with
|
||||||
|
* `hasMore`, not to make every table pretend.
|
||||||
|
*
|
||||||
|
* ── What this hook is actually for ──────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* The slicing is the trivial part. The part worth having in one place, tested,
|
||||||
|
* is what happens when the rows underneath change — which is where hand-rolled
|
||||||
|
* paging goes wrong: you filter down to three results while on page 5 and the
|
||||||
|
* table renders empty with no way back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Paged<T> {
|
||||||
|
/** 1-based, matching the design system's Pagination. */
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
/** Just this page's rows. */
|
||||||
|
rows: T[];
|
||||||
|
/** Every row, before slicing. */
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
/** 1-based inclusive range on screen, for "showing 21–40 of 96". Zero when empty. */
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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[],
|
||||||
|
options: {
|
||||||
|
pageSize?: number;
|
||||||
|
/**
|
||||||
|
* Changing this sends the table back to page 1.
|
||||||
|
*
|
||||||
|
* Clamping alone is not enough. Switching branch, day or status tab can
|
||||||
|
* hand back a DIFFERENT set of rows that happens to be at least as long as
|
||||||
|
* the old one — nothing to clamp — and the operator is left reading page 4
|
||||||
|
* of something they just started looking at. Pass whatever identifies the
|
||||||
|
* query: a day, a branch id, a status, or a template string of several.
|
||||||
|
*/
|
||||||
|
resetKey?: string | number;
|
||||||
|
} = {},
|
||||||
|
): Paged<T> {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const { resetKey } = options;
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Clamped on the way out as well as reset above.
|
||||||
|
|
||||||
|
A row set can shrink under a page without the query changing at all — a
|
||||||
|
delivery gets marked delivered and leaves the tab, someone types another
|
||||||
|
letter into the search. Reading `page` directly would then slice past the end
|
||||||
|
and render an empty table on page 5 of 2, which looks like the data
|
||||||
|
disappeared. Deriving the safe page rather than setting state in an effect
|
||||||
|
also means the correct rows render on the FIRST pass, with no empty frame in
|
||||||
|
between.
|
||||||
|
*/
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
|
||||||
|
const pageRows = useMemo(
|
||||||
|
() => rows.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||||
|
[rows, safePage, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: safePage,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
setPageSize: (size: number) => {
|
||||||
|
// Back to the first page: keeping the number would land you somewhere
|
||||||
|
// unrelated, since page 4 of 10-per-page is page 1 of 50-per-page.
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
rows: pageRows,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
from: total === 0 ? 0 : (safePage - 1) * pageSize + 1,
|
||||||
|
to: Math.min(safePage * pageSize, total),
|
||||||
|
};
|
||||||
|
}
|
||||||
60
src/components/useSelection.test.ts
Normal file
60
src/components/useSelection.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
/*
|
||||||
|
The selection logic, lifted out of the hook so it can be tested without React.
|
||||||
|
|
||||||
|
The rule that matters: a bulk action must only ever touch rows the person could
|
||||||
|
see when they chose them. Approving stock moves it, so acting on a row hidden
|
||||||
|
behind a filter is not a cosmetic bug.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function chosenOf(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||||
|
return visible.filter((id) => picked.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterToggleAll(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||||
|
const next = new Set(picked);
|
||||||
|
const everyVisibleChosen = visible.length > 0 && visible.every((id) => next.has(id));
|
||||||
|
for (const id of visible) {
|
||||||
|
if (everyVisibleChosen) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a bulk action never touches a row that was filtered away', () => {
|
||||||
|
// Ticked while the list showed everything, then the list was narrowed.
|
||||||
|
const picked = new Set([1, 2, 3]);
|
||||||
|
assert.deepEqual(chosenOf([2], picked), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('narrowing and widening again does not untick the work', () => {
|
||||||
|
// The hidden ids stay in the set; they are simply not acted on while hidden.
|
||||||
|
const picked = new Set([1, 2, 3]);
|
||||||
|
assert.deepEqual(chosenOf([1, 2, 3], picked), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all covers only what is on screen', () => {
|
||||||
|
const next = afterToggleAll([2, 3], new Set());
|
||||||
|
assert.deepEqual([...next].sort(), [2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all a second time clears exactly what it added', () => {
|
||||||
|
const picked = afterToggleAll([2, 3], new Set([9]));
|
||||||
|
const cleared = afterToggleAll([2, 3], picked);
|
||||||
|
// 9 was chosen elsewhere and is not on screen, so it survives.
|
||||||
|
assert.deepEqual([...cleared], [9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select all on an empty list does nothing', () => {
|
||||||
|
assert.equal(afterToggleAll([], new Set()).size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the header is only fully ticked when every visible row is', () => {
|
||||||
|
const visible = [1, 2];
|
||||||
|
const partial = chosenOf(visible, new Set([1]));
|
||||||
|
assert.equal(partial.length === visible.length, false);
|
||||||
|
const full = chosenOf(visible, new Set([1, 2]));
|
||||||
|
assert.equal(full.length === visible.length, true);
|
||||||
|
});
|
||||||
88
src/components/useSelection.ts
Normal file
88
src/components/useSelection.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rows a person has ticked, and the header checkbox that follows.
|
||||||
|
*
|
||||||
|
* Shared by the three screens that grew a bulk action — importing from the
|
||||||
|
* catalogue, requesting stock, and deciding requests — because the fiddly parts
|
||||||
|
* are the same every time and getting them subtly different between screens is
|
||||||
|
* how a merchant learns to distrust the tick boxes.
|
||||||
|
*
|
||||||
|
* ── The part that is easy to get wrong ──────────────────────────────────────
|
||||||
|
*
|
||||||
|
* A selection is kept against the ROWS CURRENTLY VISIBLE. Filter a list down,
|
||||||
|
* tick everything, clear the filter, and press the button: a naive
|
||||||
|
* implementation acts on rows the person could not see when they chose. So
|
||||||
|
* "select all" only ever covers what is on screen, and `chosen` is intersected
|
||||||
|
* with the visible ids before it is handed back.
|
||||||
|
*
|
||||||
|
* Ids that scroll out of view are NOT dropped from the set, because narrowing a
|
||||||
|
* search and widening it again should not silently untick the work. They are
|
||||||
|
* simply not acted on while they are hidden.
|
||||||
|
*/
|
||||||
|
export interface Selection {
|
||||||
|
/** Visible ids that are ticked — what a bulk action should act on. */
|
||||||
|
chosen: number[];
|
||||||
|
count: number;
|
||||||
|
has: (id: number) => boolean;
|
||||||
|
toggle: (id: number) => void;
|
||||||
|
/** Tick or untick everything currently visible. */
|
||||||
|
toggleAll: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
/** Every visible row is ticked. Drives the header checkbox. */
|
||||||
|
allChosen: boolean;
|
||||||
|
/** Some but not all — the indeterminate state. */
|
||||||
|
someChosen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelection(visibleIds: readonly number[]): Selection {
|
||||||
|
const [picked, setPicked] = useState<ReadonlySet<number>>(() => new Set());
|
||||||
|
|
||||||
|
const chosen = useMemo(
|
||||||
|
() => visibleIds.filter((id) => picked.has(id)),
|
||||||
|
[visibleIds, picked],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = useCallback((id: number) => {
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allChosen = visibleIds.length > 0 && chosen.length === visibleIds.length;
|
||||||
|
|
||||||
|
const toggleAll = useCallback(() => {
|
||||||
|
setPicked((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const everyVisibleChosen =
|
||||||
|
visibleIds.length > 0 && visibleIds.every((id) => next.has(id));
|
||||||
|
for (const id of visibleIds) {
|
||||||
|
if (everyVisibleChosen) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [visibleIds]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => setPicked(new Set()), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chosen,
|
||||||
|
count: chosen.length,
|
||||||
|
has: (id: number) => picked.has(id),
|
||||||
|
toggle,
|
||||||
|
toggleAll,
|
||||||
|
clear,
|
||||||
|
allChosen,
|
||||||
|
someChosen: chosen.length > 0 && !allChosen,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -8,17 +8,12 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
Mail,
|
Mail,
|
||||||
ShieldCheck,
|
Building2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { HOME_ROUTE } from '@/auth/roles';
|
import { HOME_ROUTE } from '@/auth/roles';
|
||||||
import {
|
import { checkAccount, PasswordSetupRequiredError, WrongConsoleError } from '@/auth/session';
|
||||||
checkAccount,
|
|
||||||
MIN_PASSWORD_LENGTH,
|
|
||||||
PasswordSetupRequiredError,
|
|
||||||
setInitialPassword,
|
|
||||||
} from '@/auth/session';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-in — KROW's full-bleed auth archetype.
|
* Sign-in — KROW's full-bleed auth archetype.
|
||||||
@@ -41,33 +36,61 @@ export function LoginPage() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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);
|
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
|
* Email first, always. A tenant made by `createtenantuser` and every branch
|
||||||
* made by `createtenantlocation` is spawned with an EMPTY password, so their
|
* 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
|
* 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
|
* 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
|
* So the email is checked before a password field is ever shown. An account
|
||||||
* goes straight to whichever step that account actually needs. This is what
|
* that has no password never reaches the second step: it is told to use its
|
||||||
* the old console does, and it is the right shape.
|
* 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.
|
Each step brings its own panel into view.
|
||||||
*
|
|
||||||
* Deliberately not a route: it exists only because a check just produced it,
|
The three steps are one screen, and on a phone the brand panel sits ABOVE the
|
||||||
* and a `/set-password` URL that could be opened cold would be a way to set
|
form — so the card is taller than the viewport and answering the email step
|
||||||
* any account's password from nothing.
|
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 [setupUserid, setSetupUserid] = useState<number | null>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const isFirstStep = useRef(true);
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
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 />;
|
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||||
|
|
||||||
@@ -75,14 +98,24 @@ export function LoginPage() {
|
|||||||
async function handleEmail(event: FormEvent) {
|
async function handleEmail(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
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);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
const check = await checkAccount(email);
|
const check = await checkAccount(email);
|
||||||
if (check.state === 'setup') {
|
if (check.state === 'setup') {
|
||||||
setSetupUserid(check.userid);
|
// An account that exists and has never been used. It is NOT offered a
|
||||||
setNewPassword('');
|
// password form any more: this branch is reached by anybody who types
|
||||||
setConfirmPassword('');
|
// an email, so a form here meant that knowing a merchant's address —
|
||||||
setStep('setup');
|
// 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 {
|
} else {
|
||||||
setStep('password');
|
setStep('password');
|
||||||
}
|
}
|
||||||
@@ -96,6 +129,7 @@ export function LoginPage() {
|
|||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
setIsBusy(true);
|
setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
const session = await signIn(email, password);
|
const session = await signIn(email, password);
|
||||||
@@ -105,10 +139,24 @@ export function LoginPage() {
|
|||||||
// check and the submit an administrator could have cleared the password,
|
// check and the submit an administrator could have cleared the password,
|
||||||
// and the account would otherwise dead-end on "Invalid Email".
|
// and the account would otherwise dead-end on "Invalid Email".
|
||||||
if (cause instanceof PasswordSetupRequiredError) {
|
if (cause instanceof PasswordSetupRequiredError) {
|
||||||
setSetupUserid(cause.userid);
|
// Between the probe and the submit, an administrator could have cleared
|
||||||
setNewPassword('');
|
// the password. Same answer as the probe gives: the invitation is the
|
||||||
setConfirmPassword('');
|
// only way to set one, so there is nothing to offer here.
|
||||||
setStep('setup');
|
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 {
|
} else {
|
||||||
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
|
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() {
|
function restart() {
|
||||||
setStep('email');
|
setStep('email');
|
||||||
setSetupUserid(null);
|
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setNewPassword('');
|
|
||||||
setConfirmPassword('');
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* `handleSetup` stood here, with its own password form. Setting a first
|
||||||
* Set the password, then sign in with it.
|
password moved to `SetPasswordPage`, reached only from a signed invitation
|
||||||
*
|
— this screen is public, so a form here meant knowing a merchant's email
|
||||||
* Signing in afterwards rather than sending the person back to the form: they
|
was enough to claim their account. */
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
|
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
|
||||||
const canSetup =
|
|
||||||
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -168,24 +187,38 @@ export function LoginPage() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="login-split"
|
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={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 1024,
|
maxWidth: 1024,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--color-line)',
|
border: 'var(--card-border)',
|
||||||
borderRadius: 28,
|
borderRadius: 'var(--card-radius)',
|
||||||
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
|
boxShadow: 'var(--card-shadow)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrandPanel />
|
<BrandPanel />
|
||||||
{step !== 'setup' ? (
|
{/* 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
|
<FormPanel
|
||||||
step={step}
|
step={step}
|
||||||
email={email}
|
email={email}
|
||||||
password={password}
|
password={password}
|
||||||
isPasswordVisible={isPasswordVisible}
|
isPasswordVisible={isPasswordVisible}
|
||||||
error={error}
|
error={error}
|
||||||
|
notice={notice}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
onEmail={setEmail}
|
onEmail={setEmail}
|
||||||
@@ -194,22 +227,7 @@ export function LoginPage() {
|
|||||||
onSubmit={step === 'email' ? handleEmail : handleSubmit}
|
onSubmit={step === 'email' ? handleEmail : handleSubmit}
|
||||||
onBack={restart}
|
onBack={restart}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -352,6 +370,8 @@ interface FormPanelProps {
|
|||||||
password: string;
|
password: string;
|
||||||
isPasswordVisible: boolean;
|
isPasswordVisible: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** The wrong-console sentence. Rendered beside `error`, never as one. */
|
||||||
|
notice: string | null;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
onEmail: (value: string) => void;
|
onEmail: (value: string) => void;
|
||||||
@@ -367,6 +387,7 @@ function FormPanel({
|
|||||||
password,
|
password,
|
||||||
isPasswordVisible,
|
isPasswordVisible,
|
||||||
error,
|
error,
|
||||||
|
notice,
|
||||||
isBusy,
|
isBusy,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
onEmail,
|
onEmail,
|
||||||
@@ -459,6 +480,7 @@ function FormPanel({
|
|||||||
something the system does not do. */}
|
something the system does not do. */}
|
||||||
|
|
||||||
<ErrorNote message={error} />
|
<ErrorNote message={error} />
|
||||||
|
<ConsoleNote message={notice} />
|
||||||
|
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
@@ -492,174 +514,11 @@ function FormPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* `SetupPanelProps`, `SetupPanel` and the `Hint` line they used stood here — a
|
||||||
Right — first sign-in, setting the password
|
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
|
||||||
interface SetupPanelProps {
|
was enough to claim their account. */
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────────
|
||||||
Shared form furniture
|
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({
|
function SubmitButton({
|
||||||
canSubmit,
|
canSubmit,
|
||||||
isBusy,
|
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,17 +1,22 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { Switch } from '@astryxdesign/core/Switch';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
import { Skeleton } from '@astryxdesign/core/Skeleton';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { Token } from '@astryxdesign/core/Token';
|
import { Token } from '@astryxdesign/core/Token';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
|
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
|
||||||
import { catalogueKey } from '@/api/catalogue';
|
import { SearchInput } from '@/components/SearchInput';
|
||||||
|
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
|
||||||
|
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
||||||
|
import { APP_BROWSE_CATEGORY } from './tenantCategories';
|
||||||
|
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
|
||||||
|
import { useSelection } from '@/components/useSelection';
|
||||||
import { productsApi } from '@/api/products';
|
import { productsApi } from '@/api/products';
|
||||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
@@ -23,6 +28,7 @@ import {
|
|||||||
} from '@/queries/hooks';
|
} from '@/queries/hooks';
|
||||||
import { CatalogueCard } from './CatalogueCard';
|
import { CatalogueCard } from './CatalogueCard';
|
||||||
import { CatalogueSidebar } from './CatalogueSidebar';
|
import { CatalogueSidebar } from './CatalogueSidebar';
|
||||||
|
import { BulkImportDrawer } from './BulkImportDrawer';
|
||||||
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
|
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
@@ -32,8 +38,6 @@ export interface CatalogueBrowserProps {
|
|||||||
tenantid: number | undefined;
|
tenantid: number | undefined;
|
||||||
/** The outlet the import is written against. Required by the backend. */
|
/** The outlet the import is written against. Required by the backend. */
|
||||||
locationid: number | undefined;
|
locationid: number | undefined;
|
||||||
/** The tenant's own categories, for the one field the catalogue cannot supply. */
|
|
||||||
categoryOptions: { value: string; label: string }[];
|
|
||||||
/** Wording on the card and drawer buttons. */
|
/** Wording on the card and drawer buttons. */
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
/**
|
/**
|
||||||
@@ -54,15 +58,6 @@ export interface CatalogueBrowserProps {
|
|||||||
* rather than one at a time into somebody else's shop.
|
* rather than one at a time into somebody else's shop.
|
||||||
*/
|
*/
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
/**
|
|
||||||
* Lift the search row onto the page's tab row.
|
|
||||||
*
|
|
||||||
* Only true where a tab row actually exists — the Store Admin's Inventory.
|
|
||||||
* On a page without one the offset drags the row up over the page header's
|
|
||||||
* own actions and swallows their clicks, which is exactly what it did to the
|
|
||||||
* platform catalogue's mode toggle.
|
|
||||||
*/
|
|
||||||
alignWithTabs?: boolean;
|
|
||||||
/** Why importing is unavailable, if it is. */
|
/** Why importing is unavailable, if it is. */
|
||||||
blockedReason?: string;
|
blockedReason?: string;
|
||||||
}
|
}
|
||||||
@@ -87,22 +82,43 @@ export interface CatalogueBrowserProps {
|
|||||||
export function CatalogueBrowser({
|
export function CatalogueBrowser({
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid,
|
locationid,
|
||||||
categoryOptions,
|
|
||||||
actionLabel,
|
actionLabel,
|
||||||
onImport,
|
onImport,
|
||||||
scope,
|
scope,
|
||||||
blockedReason,
|
blockedReason,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
alignWithTabs,
|
|
||||||
}: CatalogueBrowserProps) {
|
}: CatalogueBrowserProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
|
||||||
const [brand, setBrand] = useState('');
|
const [brand, setBrand] = useState('');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
const [importInto, setImportInto] = useState('');
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
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 [open, setOpen] = useState<CatalogueProduct | null>(null);
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -173,11 +189,77 @@ export function CatalogueBrowser({
|
|||||||
const knownTotal = !category && !debounced ? brandTotal : undefined;
|
const knownTotal = !category && !debounced ? brandTotal : undefined;
|
||||||
|
|
||||||
const importedKeys = useMemo(() => {
|
const importedKeys = useMemo(() => {
|
||||||
const set = new Set((imported.data ?? []).map(catalogueKey));
|
// Both keys per ref, not one. During the changeover a ref can carry the
|
||||||
|
// stable key it has just acquired AND the id it was imported under, and a
|
||||||
|
// product that has not been relinked yet is still genuinely imported.
|
||||||
|
const set = new Set((imported.data ?? []).flatMap(catalogueKeysOf));
|
||||||
for (const key of justImported) set.add(key);
|
for (const key of justImported) set.add(key);
|
||||||
return set;
|
return set;
|
||||||
}, [imported.data, justImported]);
|
}, [imported.data, justImported]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rows are ticked for a bulk import.
|
||||||
|
*
|
||||||
|
* Only rows that are NOT already imported can be selected. A product already
|
||||||
|
* on the shelf has nothing to do, and letting it be ticked would put it in
|
||||||
|
* the count on the button — "Add 12" that adds nine is worse than no count.
|
||||||
|
*/
|
||||||
|
const selectableIds = useMemo(
|
||||||
|
() => rows.filter((product) => !importedKeys.has(catalogueKey(product))).map((product) => product.id),
|
||||||
|
[rows, importedKeys],
|
||||||
|
);
|
||||||
|
const selection = useSelection(selectableIds);
|
||||||
|
|
||||||
|
const importMany = useMutation({
|
||||||
|
/*
|
||||||
|
Every distinct category in the batch resolved in ONE call, then each row
|
||||||
|
built with its own id.
|
||||||
|
|
||||||
|
Not `products.map(importRowFor)`: `map` hands the callback the array
|
||||||
|
INDEX as its second argument, which is a number, so passing the row
|
||||||
|
builder directly type-checks perfectly and files the first product under
|
||||||
|
category 0, the second under 1, and so on. It was written that way for a
|
||||||
|
one-argument builder and stayed valid the moment the second argument
|
||||||
|
arrived.
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
choices.get(catalogueKey(product)) ?? showScoreOnAdd,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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.
|
||||||
|
setJustImported((set) => {
|
||||||
|
const next = new Set(set);
|
||||||
|
for (const product of products) next.add(catalogueKey(product));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
selection.clear();
|
||||||
|
await Promise.all([
|
||||||
|
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
|
||||||
|
client.invalidateQueries({ queryKey: queryKeys.products.all }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const importOne = useMutation({
|
const importOne = useMutation({
|
||||||
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
|
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -188,22 +270,87 @@ export function CatalogueBrowser({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importDirect(product: CatalogueProduct) {
|
|
||||||
if (!tenantid || !locationid) return;
|
|
||||||
const key = catalogueKey(product);
|
|
||||||
setBusy(key);
|
/**
|
||||||
|
* One import row.
|
||||||
|
*
|
||||||
|
* Extracted so the single-product button and the bulk action build the SAME
|
||||||
|
* row. Two copies of this object is how a bulk import quietly writes a
|
||||||
|
* different category, or a price where the single one writes none.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The category id an import should use for one catalogue product.
|
||||||
|
*
|
||||||
|
* The catalogue already knows what this is — "Spices & Masalas" sits on the
|
||||||
|
* row — and that answer comes from the same deterministic ladder the
|
||||||
|
* pipeline runs. Discarding it in favour of whatever single category the
|
||||||
|
* tenant happened to have is how an Aachi masala arrived filed under
|
||||||
|
* "Category 2".
|
||||||
|
*
|
||||||
|
* An EXPLICIT choice still wins, which is the ladder's own first rule: if the
|
||||||
|
* operator has touched the picker, that is their answer and it is kept. Until
|
||||||
|
* they do, `importInto` is null and the catalogue's own category is used.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The same decision for a whole batch, in one round trip.
|
||||||
|
*
|
||||||
|
* A request per product would open one connection per row from a shop's
|
||||||
|
* browser — the mistake the catalogue reconciliation already documents.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The category NAME for one catalogue product, always one of the 31.
|
||||||
|
*
|
||||||
|
* The catalogue's own value is used when it is canonical; when it is not —
|
||||||
|
* "Food - Mixes", "Pickles & Chutneys", "Dairy - Desserts" and the rest,
|
||||||
|
* about a third of the rows sampled — the product is classified by the ladder
|
||||||
|
* instead, so nothing is filed under a name the published list does not have.
|
||||||
|
*/
|
||||||
|
function categoryNameFor(product: CatalogueProduct): string {
|
||||||
|
return categoryForCatalogueProduct({
|
||||||
|
catalogueCategory: product.category,
|
||||||
|
title: product.product_name ?? '',
|
||||||
|
description: product.description ?? '',
|
||||||
|
packSize: product.size ?? '',
|
||||||
|
}).category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The aisle ids the app groups by, read from the platform's own list.
|
||||||
|
*
|
||||||
|
* One call for a whole batch. It is `productsubcategories` for category 2 —
|
||||||
|
* ten rows, shared by every tenant — and matching on the NAME rather than
|
||||||
|
* trusting a remembered id is what keeps this off the app's other
|
||||||
|
* subcategory table, which carries the same ten names five ids lower.
|
||||||
|
*/
|
||||||
|
async function aisleIds(): Promise<Map<string, number>> {
|
||||||
try {
|
try {
|
||||||
await importOne.mutateAsync({
|
return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY));
|
||||||
tenantid,
|
} catch {
|
||||||
locationid,
|
return aisleIdsFrom(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importRowFor(
|
||||||
|
product: CatalogueProduct,
|
||||||
|
subcategoryid: number,
|
||||||
|
showHealthScore: boolean | undefined,
|
||||||
|
): ImportCatalogueProductRequest {
|
||||||
|
return {
|
||||||
|
tenantid: tenantid as number,
|
||||||
|
locationid: locationid as number,
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
catalogueid: product.id,
|
catalogueid: product.id,
|
||||||
// Uncategorised beats wrongly categorised — the backend leaves this
|
/* ALWAYS 2, and this is not a placeholder.
|
||||||
// optional for exactly that reason. An unclassified product is visibly
|
`getproductsbysubcategory` filters on `categoryid = 2` — the value the
|
||||||
// unfinished; a wrongly classified one looks done and is only found by
|
app sends — so a per-product categoryid does not label the product, it
|
||||||
// somebody browsing the wrong aisle.
|
deletes it from the app's view. What the shopper actually reads as the
|
||||||
categoryid: Number(importInto) || 0,
|
aisle heading is the subcategory below, which until now was 0 on every
|
||||||
subcategoryid: 0,
|
product on the platform: one bucket, "Uncategorized", holding the shop.
|
||||||
|
See `appAisle.ts` for the endpoint this is measured against. */
|
||||||
|
categoryid: APP_BROWSE_CATEGORY,
|
||||||
|
subcategoryid,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
stocktype: 'in',
|
stocktype: 'in',
|
||||||
status: 'Draft',
|
status: 'Draft',
|
||||||
@@ -211,8 +358,59 @@ export function CatalogueBrowser({
|
|||||||
retailprice: 0,
|
retailprice: 0,
|
||||||
productcost: 0,
|
productcost: 0,
|
||||||
taxpercent: 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()),
|
||||||
|
showHealthScore,
|
||||||
|
),
|
||||||
|
);
|
||||||
setJustImported((set) => new Set(set).add(key));
|
setJustImported((set) => new Set(set).add(key));
|
||||||
|
setOpen(null);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
@@ -221,17 +419,6 @@ export function CatalogueBrowser({
|
|||||||
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
|
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
|
||||||
const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason);
|
const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason);
|
||||||
|
|
||||||
/**
|
|
||||||
* "Import into" lives in the drawer, at the moment of the decision.
|
|
||||||
*
|
|
||||||
* It used to sit in the filter row as well, which put a question about where
|
|
||||||
* ONE product should be filed next to three controls that change what the
|
|
||||||
* whole grid shows — and it read as a fourth filter. It is only offered when
|
|
||||||
* this component performs the import itself: when the caller takes over
|
|
||||||
* (`onImport`), its own form collects the category along with the price, and
|
|
||||||
* asking twice on one screen gets two answers that can disagree.
|
|
||||||
*/
|
|
||||||
const showCategoryPicker = !isReadOnly && !onImport && categoryOptions.length > 0;
|
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setBrand('');
|
setBrand('');
|
||||||
@@ -251,33 +438,31 @@ export function CatalogueBrowser({
|
|||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
{scope}
|
{scope}
|
||||||
|
|
||||||
{/* Pulled up onto the tab row's line and right-aligned, so search sits
|
{/* Search, filters and the rail toggle, on their own line.
|
||||||
level with the page selector rather than on a line of its own — the
|
|
||||||
tab row is mostly empty to the right and was reserving a whole row
|
|
||||||
below it for four controls.
|
|
||||||
|
|
||||||
The pull-up is a CSS offset rather than the controls being rendered
|
They used to be PULLED UP onto the page's tab row by a -42px offset
|
||||||
inside the tab row, because the tab row belongs to the page
|
(`alignWithTabs`, `.catalogue-controls`), because that row looked
|
||||||
(`InventoryPage`) and these controls belong to the browser's own
|
mostly empty to the right. It is not empty any more — Inventory's tab
|
||||||
state. Rendering them there would mean lifting `keyword` and the rail
|
row carries "Upload sheet" at its right end — and the offset does not
|
||||||
toggle into two different pages to keep one row tidy. It reverts to
|
move a narrow control into a gap, it lays a FULL-WIDTH row on top of
|
||||||
its own line below 900px, where the tabs need the width.
|
the whole tab row: measured at 24 → 1090px, covering all four tabs and
|
||||||
|
the button. Clicks landed on this element and nothing switched tabs,
|
||||||
|
which read as "the tabs stop working once you open the Catalogue".
|
||||||
|
|
||||||
|
That was the second time. The prop's own note recorded it happening to
|
||||||
|
the platform catalogue's mode toggle, and it was fixed there by not
|
||||||
|
passing the prop — leaving the offset in place to catch the next row
|
||||||
|
that grew an action. Two controls cannot share one right-hand corner,
|
||||||
|
so search keeps its own line and the tab row keeps its button.
|
||||||
|
|
||||||
It stays above both columns rather than inside the rail: the search
|
It stays above both columns rather than inside the rail: the search
|
||||||
narrows the whole catalogue, and the rail only lists brands. */}
|
narrows the whole catalogue, and the rail only lists brands. */}
|
||||||
<HStack
|
{/* Right-hand corner. The filter toggle and the search belong at the end
|
||||||
gap={1}
|
of the row, matching where search sits on Sales, Reports and Products,
|
||||||
align="center"
|
rather than above the brand rail where they read as the rail's own
|
||||||
wrap="wrap"
|
controls instead of the whole catalogue's. */}
|
||||||
justify="end"
|
<HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
|
||||||
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
|
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
>
|
|
||||||
{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
|
<IconButton
|
||||||
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
|
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
|
||||||
icon={<Funnel size={15} />}
|
icon={<Funnel size={15} />}
|
||||||
@@ -285,17 +470,23 @@ export function CatalogueBrowser({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsFiltersOpen((open) => !open)}
|
onClick={() => setIsFiltersOpen((open) => !open)}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<SearchInput
|
||||||
label="Search the catalogue"
|
label="Search the catalogue"
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
width={220}
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
placeholder="Search products…"
|
placeholder="Search products…"
|
||||||
startIcon={<Search size={14} />}
|
width="full"
|
||||||
hasClear
|
|
||||||
/>
|
/>
|
||||||
|
</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}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
|
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
|
||||||
@@ -305,7 +496,11 @@ export function CatalogueBrowser({
|
|||||||
isLoading={brands.isLoading}
|
isLoading={brands.isLoading}
|
||||||
brand={brand}
|
brand={brand}
|
||||||
category={category}
|
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}
|
isLoadingCategories={categories.isLoading}
|
||||||
onBrand={setBrand}
|
onBrand={setBrand}
|
||||||
onCategory={setCategory}
|
onCategory={setCategory}
|
||||||
@@ -357,18 +552,99 @@ export function CatalogueBrowser({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Bulk import. Hidden entirely when there is nothing to import
|
||||||
|
into — a merchant with no outlet selected is already told why
|
||||||
|
by `blockedReason`, and a second dead control below it adds
|
||||||
|
nothing. */}
|
||||||
|
{canImport && !onImport && selectableIds.length > 0 ? (
|
||||||
|
<div className="bulkbar" data-active={selection.count > 0 ? 'yes' : 'no'}>
|
||||||
|
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||||
|
<label className="bulkbar-all">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selection.allChosen}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = selection.someChosen;
|
||||||
|
}}
|
||||||
|
onChange={selection.toggleAll}
|
||||||
|
aria-label={
|
||||||
|
selection.allChosen ? 'Clear selection' : 'Select every product on this page'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text type="body" size="sm">
|
||||||
|
{selection.count > 0
|
||||||
|
? `${selection.count} selected`
|
||||||
|
: `Select all ${selectableIds.length} on this page`}
|
||||||
|
</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
|
||||||
|
label="Clear"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={importMany.isPending}
|
||||||
|
onClick={selection.clear}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
importMany.isPending
|
||||||
|
? 'Adding…'
|
||||||
|
: `${actionLabel} (${selection.count})`
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
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={() =>
|
||||||
|
setBatch(rows.filter((product) => selection.has(product.id)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="product-grid">
|
<div className="product-grid">
|
||||||
{rows.map((product) => {
|
{rows.map((product) => {
|
||||||
const key = catalogueKey(product);
|
const key = catalogueKey(product);
|
||||||
|
const isImported = importedKeys.has(key);
|
||||||
return (
|
return (
|
||||||
<CatalogueCard
|
<CatalogueCard
|
||||||
key={key}
|
key={key}
|
||||||
product={product}
|
product={product}
|
||||||
isImported={importedKeys.has(key)}
|
isImported={isImported}
|
||||||
isBusy={busy === key}
|
isBusy={busy === key || importMany.isPending}
|
||||||
isDisabled={!canImport}
|
isDisabled={!canImport}
|
||||||
actionLabel={actionLabel}
|
actionLabel={actionLabel}
|
||||||
onOpen={() => setOpen(product)}
|
onOpen={() => setOpen(product)}
|
||||||
|
{...(canImport && !onImport && !isImported
|
||||||
|
? {
|
||||||
|
isSelected: selection.has(product.id),
|
||||||
|
onSelect: () => selection.toggle(product.id),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
{...(isReadOnly ? {} : { onImport: () => run(product) })}
|
{...(isReadOnly ? {} : { onImport: () => run(product) })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -393,25 +669,51 @@ export function CatalogueBrowser({
|
|||||||
</VStack>
|
</VStack>
|
||||||
</div>
|
</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 ? (
|
{open ? (
|
||||||
<CatalogueDetailDrawer
|
<CatalogueDetailDrawer
|
||||||
product={open}
|
product={open}
|
||||||
isImported={importedKeys.has(catalogueKey(open))}
|
isImported={importedKeys.has(catalogueKey(open))}
|
||||||
isBusy={busy === catalogueKey(open)}
|
isBusy={busy === catalogueKey(open)}
|
||||||
actionLabel={actionLabel}
|
actionLabel={actionLabel}
|
||||||
{...(showCategoryPicker
|
|
||||||
? { categoryOptions, categoryid: importInto, onCategoryChange: setImportInto }
|
|
||||||
: {})}
|
|
||||||
{...(canImport && !isReadOnly
|
{...(canImport && !isReadOnly
|
||||||
? {
|
? {
|
||||||
onImport: () => {
|
onImport: (showHealthScore: boolean | undefined) => {
|
||||||
const product = open;
|
const product = open;
|
||||||
if (onImport) setOpen(null);
|
// A caller that owns the import — the shelving flow — takes
|
||||||
run(product);
|
// 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);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
{...(blockedReason ? { blockedReason } : {})}
|
{...(blockedReason
|
||||||
|
? // The only reason left is the caller's — "no outlet selected".
|
||||||
|
// "This merchant has no category yet" used to sit here too and is
|
||||||
|
// gone: the category is derived from the product and created on
|
||||||
|
// demand, so there is no longer a state where a merchant has
|
||||||
|
// nowhere to file something.
|
||||||
|
{ blockedReason }
|
||||||
|
: {})}
|
||||||
onClose={() => setOpen(null)}
|
onClose={() => setOpen(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type MouseEvent } from 'react';
|
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';
|
import type { CatalogueProduct } from '@/api/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,8 @@ export function CatalogueCard({
|
|||||||
actionLabel,
|
actionLabel,
|
||||||
onOpen,
|
onOpen,
|
||||||
onImport,
|
onImport,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
product: CatalogueProduct;
|
product: CatalogueProduct;
|
||||||
isImported: boolean;
|
isImported: boolean;
|
||||||
@@ -37,6 +39,13 @@ export function CatalogueCard({
|
|||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
|
/**
|
||||||
|
* Present only when this card can take part in a bulk import. Absent for a
|
||||||
|
* product already on the shelf, and absent entirely on read-only views — so
|
||||||
|
* the tick box appears exactly where it does something.
|
||||||
|
*/
|
||||||
|
isSelected?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const images = product.images ?? [];
|
const images = product.images ?? [];
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
@@ -89,6 +98,22 @@ export function CatalogueCard({
|
|||||||
controls.
|
controls.
|
||||||
*/}
|
*/}
|
||||||
<div className="pcard-media">
|
<div className="pcard-media">
|
||||||
|
{onSelect ? (
|
||||||
|
<label
|
||||||
|
className="pcard-tick"
|
||||||
|
/* The card body is a click target that opens the drawer. Ticking
|
||||||
|
must not also open it, so the label swallows the event before it
|
||||||
|
reaches the overlay underneath. */
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(isSelected)}
|
||||||
|
onChange={onSelect}
|
||||||
|
aria-label={`Select ${product.product_name}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
<img
|
||||||
// Keyed by the URL so a fall-through to the next photo actually
|
// Keyed by the URL so a fall-through to the next photo actually
|
||||||
@@ -120,13 +145,6 @@ export function CatalogueCard({
|
|||||||
what the packaging itself carries in larger type than we could. */}
|
what the packaging itself carries in larger type than we could. */}
|
||||||
{isImported ? <span className="pcard-owned">In your list</span> : null}
|
{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 —
|
{/* 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
|
an arrow that does nothing is worse than no arrow, and most of a
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
|
||||||
import { Divider } from '@astryxdesign/core/Divider';
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
|
||||||
import { Lightbox } from '@astryxdesign/core/Lightbox';
|
import { Lightbox } from '@astryxdesign/core/Lightbox';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Check, DownloadCloud, ImageOff } from 'lucide-react';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { Token } from '@astryxdesign/core/Token';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
|
||||||
import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react';
|
|
||||||
import type { CatalogueProduct } from '@/api/types';
|
import type { CatalogueProduct } from '@/api/types';
|
||||||
import { Drawer } from '@/features/store-admin/Drawer';
|
import { Drawer } from '@/features/store-admin/Drawer';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Bullets,
|
||||||
|
DrawerButton,
|
||||||
|
DrawerCard,
|
||||||
|
Metric,
|
||||||
|
Metrics,
|
||||||
|
Mono,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
} from '@/features/store-admin/drawerKit';
|
||||||
|
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
|
||||||
|
import { aisleForCategory } from '@/features/store-admin/appAisle';
|
||||||
|
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One global-catalogue product, in full.
|
* One global-catalogue product, in full.
|
||||||
@@ -37,12 +43,15 @@ export interface CatalogueDetailDrawerProps {
|
|||||||
isImported: boolean;
|
isImported: boolean;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
/** The tenant's own categories. Omit to hide the picker. */
|
|
||||||
categoryOptions?: { value: string; label: string }[];
|
|
||||||
categoryid?: string;
|
|
||||||
onCategoryChange?: (value: string) => void;
|
|
||||||
/** Absent when the caller has nowhere to import to yet. */
|
/** 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. */
|
/** Shown in place of the action when importing is unavailable. */
|
||||||
blockedReason?: string;
|
blockedReason?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -53,14 +62,40 @@ export function CatalogueDetailDrawer({
|
|||||||
isImported,
|
isImported,
|
||||||
isBusy,
|
isBusy,
|
||||||
actionLabel = 'Add to my products',
|
actionLabel = 'Add to my products',
|
||||||
categoryOptions,
|
|
||||||
categoryid = '',
|
|
||||||
onCategoryChange,
|
|
||||||
onImport,
|
onImport,
|
||||||
blockedReason,
|
blockedReason,
|
||||||
onClose,
|
onClose,
|
||||||
}: CatalogueDetailDrawerProps) {
|
}: CatalogueDetailDrawerProps) {
|
||||||
|
/* Which of the 31 this product will be filed under, and why.
|
||||||
|
Shown, not asked. It used to be a dropdown here, defaulting to whatever the
|
||||||
|
merchant's list happened to hold — which is how a masala arrived on a shelf
|
||||||
|
as "Category 2". The classification is the catalogue team's, the same one
|
||||||
|
the customer app's filter is built on, so there is nothing for a person to
|
||||||
|
decide: what is left is telling them what it decided. */
|
||||||
|
const suggested = categoryForCatalogueProduct({
|
||||||
|
catalogueCategory: product.category,
|
||||||
|
title: product.product_name,
|
||||||
|
description: product.description ?? '',
|
||||||
|
packSize: product.size ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The app groups by subcategory, not category — see `appAisle.ts`. */
|
||||||
|
const aisle = aisleForCategory(suggested.category);
|
||||||
|
|
||||||
const images = product.images ?? [];
|
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 [heroAt, setHeroAt] = useState(0);
|
||||||
const [isZoomed, setIsZoomed] = useState(false);
|
const [isZoomed, setIsZoomed] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -88,29 +123,35 @@ export function CatalogueDetailDrawer({
|
|||||||
<Drawer
|
<Drawer
|
||||||
title={product.product_name}
|
title={product.product_name}
|
||||||
subtitle={`${brand} · catalogue id ${product.id}`}
|
subtitle={`${brand} · catalogue id ${product.id}`}
|
||||||
titleAside={isImported ? <Token label="In your list" size="sm" color="green" /> : undefined}
|
|
||||||
width={540}
|
width={540}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
|
||||||
|
{...(!isImported && !blockedReason && onImport
|
||||||
|
? {
|
||||||
|
isFooterFilled: true,
|
||||||
|
footer: (
|
||||||
|
<DrawerButton
|
||||||
|
label={isBusy ? 'Adding…' : actionLabel}
|
||||||
|
variant="primary"
|
||||||
|
icon={<DownloadCloud size={15} />}
|
||||||
|
isDisabled={Boolean(isBusy)}
|
||||||
|
onClick={() => onImport(hasScore ? showScore : undefined)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
>
|
>
|
||||||
{/* The photograph, at the size a label can be read at. Click to zoom —
|
{/* The photograph, at the size a label can be read at. Click to zoom —
|
||||||
at card size the ingredients and the net weight are not legible. */}
|
at card size the ingredients and the net weight are not legible. */}
|
||||||
<Card padding={0} elevation="none">
|
<Section>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="drawer-figure"
|
||||||
|
data-tall="true"
|
||||||
aria-label="Open photo full size"
|
aria-label="Open photo full size"
|
||||||
onClick={() => setIsZoomed(true)}
|
onClick={() => setIsZoomed(true)}
|
||||||
disabled={!hero || failed}
|
disabled={!hero || failed}
|
||||||
style={{
|
style={{ width: '100%', cursor: hero && !failed ? 'zoom-in' : 'default' }}
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
height: 280,
|
|
||||||
padding: 0,
|
|
||||||
border: '1px solid var(--color-line)',
|
|
||||||
borderRadius: 16,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
cursor: hero && !failed ? 'zoom-in' : 'default',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{hero && !failed ? (
|
{hero && !failed ? (
|
||||||
<img
|
<img
|
||||||
@@ -118,165 +159,119 @@ export function CatalogueDetailDrawer({
|
|||||||
alt={product.product_name}
|
alt={product.product_name}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
style={{
|
style={{ mixBlendMode: 'multiply' }}
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'contain',
|
|
||||||
mixBlendMode: 'multiply',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
|
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{images.length > 1 ? (
|
{images.length > 1 ? (
|
||||||
<VStack gap={0.5}>
|
<>
|
||||||
<HStack gap={0.5} align="center">
|
<div className="drawer-thumbs">
|
||||||
<span style={{ color: 'var(--color-ink-4)', display: 'flex' }}>
|
|
||||||
<Info size={12} />
|
|
||||||
</span>
|
|
||||||
<Text type="body" size="xsm" color="secondary">
|
|
||||||
{images.length} photos — only the first is imported
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
|
|
||||||
{images.map((src, index) => (
|
{images.map((src, index) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
// Index, not the URL: a catalogue row can list the same photo
|
// Index, not the URL: a catalogue row can list the same photo
|
||||||
// twice, and a duplicate key makes React drop one thumbnail and
|
// twice, and a duplicate key makes React drop one thumbnail
|
||||||
// mis-track the rest as you page through them.
|
// and mis-track the rest as you page through them.
|
||||||
key={`${index}-${src}`}
|
key={`${index}-${src}`}
|
||||||
|
className="drawer-thumb"
|
||||||
|
data-active={index === heroAt}
|
||||||
aria-label={`Show photo ${index + 1}`}
|
aria-label={`Show photo ${index + 1}`}
|
||||||
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
|
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
|
||||||
style={{
|
|
||||||
width: 58,
|
|
||||||
height: 58,
|
|
||||||
flex: 'none',
|
|
||||||
padding: 2,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border:
|
|
||||||
index === heroAt
|
|
||||||
? '2px solid var(--color-brand)'
|
|
||||||
: '1px solid var(--color-line)',
|
|
||||||
opacity: index === heroAt ? 1 : 0.6,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<img
|
<img src={src} alt="" referrerPolicy="no-referrer" />
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</VStack>
|
<span className="drawer-metric-note">
|
||||||
|
{images.length} photos — only the first is imported
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* A RANGE, not a price. What the shop charges is set after the import,
|
{/* 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. */}
|
and conflating the two is how a catalogue figure ends up on a shelf. */}
|
||||||
<Card padding={0} elevation="low">
|
{/* The kit's own metric, not a 22px figure typed here. It was the largest
|
||||||
<HStack justify="between" align="center" gap={2} padding={2}>
|
type in any drawer in the console and it sat on a catalogue REFERENCE
|
||||||
<VStack gap={0}>
|
— louder than the selling price in the product drawer next to it. */}
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<Metrics cols={2}>
|
||||||
MARKET PRICE RANGE
|
<Metric label="Market price range" value={product.price_range ?? '—'} />
|
||||||
</Text>
|
<Metric label="Pack size" value={product.size || '—'} isSmall />
|
||||||
<Text type="large" weight="semibold" hasTabularNumbers>
|
</Metrics>
|
||||||
{product.price_range ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
{product.size ? <Token label={product.size} size="md" /> : null}
|
|
||||||
</HStack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{product.description ? (
|
{product.description ? (
|
||||||
<VStack gap={0.5}>
|
<Section title="Description">
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<p className="drawer-prose">{product.description}</p>
|
||||||
DESCRIPTION
|
</Section>
|
||||||
</Text>
|
|
||||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
|
||||||
{product.description}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Health score ──────────────────────────────────────────────────
|
||||||
|
On the CATALOGUE drawer as well as the tenant one, and this is the
|
||||||
|
drawer where it actually has data: the scored products all live in the
|
||||||
|
global catalogue. A merchant's own shelf overlaps it barely at all
|
||||||
|
today — 0 of 26 — so a panel only on the tenant product page shows
|
||||||
|
nothing to anybody, which is exactly what happened.
|
||||||
|
|
||||||
|
`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 ? (
|
{product.highlights?.length || product.nutrients?.length ? (
|
||||||
<div className="form-grid">
|
<div className="drawer-bullets">
|
||||||
{product.highlights?.length ? (
|
{product.highlights?.length ? (
|
||||||
<VStack gap={0.5}>
|
<div className="drawer-section">
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<h4 className="drawer-section-title">Highlights</h4>
|
||||||
HIGHLIGHTS
|
<Bullets items={product.highlights} />
|
||||||
</Text>
|
</div>
|
||||||
<VStack gap={0}>
|
|
||||||
{product.highlights.map((line, index) => (
|
|
||||||
<Text
|
|
||||||
key={`${index}-${line}`}
|
|
||||||
type="body"
|
|
||||||
size="xsm"
|
|
||||||
color="secondary"
|
|
||||||
style={{ lineHeight: 1.6 }}
|
|
||||||
>
|
|
||||||
• {line}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
) : null}
|
) : null}
|
||||||
{product.nutrients?.length ? (
|
{product.nutrients?.length ? (
|
||||||
<VStack gap={0.5}>
|
<div className="drawer-section">
|
||||||
<Text type="label" size="xsm" color="secondary">
|
<h4 className="drawer-section-title">Nutrition</h4>
|
||||||
NUTRITION
|
<Bullets items={product.nutrients} />
|
||||||
</Text>
|
</div>
|
||||||
<VStack gap={0}>
|
|
||||||
{product.nutrients.map((line, index) => (
|
|
||||||
<Text
|
|
||||||
key={`${index}-${line}`}
|
|
||||||
type="body"
|
|
||||||
size="xsm"
|
|
||||||
color="secondary"
|
|
||||||
style={{ lineHeight: 1.6 }}
|
|
||||||
>
|
|
||||||
• {line}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{facts.length > 0 ? (
|
{facts.length > 0 ? (
|
||||||
<>
|
<Section title="Catalogue record">
|
||||||
<Divider />
|
<DrawerCard>
|
||||||
<VStack gap={1}>
|
|
||||||
{facts.map((fact) => (
|
{facts.map((fact) => (
|
||||||
<HStack key={fact.label} justify="between" align="start" gap={2}>
|
<Row
|
||||||
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
|
key={fact.label}
|
||||||
{fact.label}
|
label={fact.label}
|
||||||
</Text>
|
value={
|
||||||
<Text
|
fact.isMono ? (
|
||||||
type="body"
|
<Mono>{fact.value}</Mono>
|
||||||
size="xsm"
|
) : (
|
||||||
style={{
|
fact.value
|
||||||
textAlign: 'right',
|
)
|
||||||
...(fact.isMono ? { fontFamily: 'var(--font-mono)' } : {}),
|
}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
{fact.value}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</DrawerCard>
|
||||||
</>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* The action, last, because everything above is what the decision is
|
{/* The action, last, because everything above is what the decision is
|
||||||
made on. */}
|
made on. The button itself lives in the fixed bar; what stays here is
|
||||||
|
where it will be filed and the warning about what importing does not do. */}
|
||||||
{isImported ? (
|
{isImported ? (
|
||||||
<Banner
|
<Banner
|
||||||
status="success"
|
status="success"
|
||||||
@@ -287,33 +282,43 @@ export function CatalogueDetailDrawer({
|
|||||||
) : blockedReason ? (
|
) : blockedReason ? (
|
||||||
<Banner status="warning" title="Cannot import yet" description={blockedReason} />
|
<Banner status="warning" title="Cannot import yet" description={blockedReason} />
|
||||||
) : onImport ? (
|
) : onImport ? (
|
||||||
<Card padding={0} elevation="low">
|
<Section title="Import into my products">
|
||||||
<VStack gap={1.5} padding={2}>
|
<DrawerCard>
|
||||||
{categoryOptions ? (
|
<Row
|
||||||
<Selector
|
label="Filed under"
|
||||||
label="Import into"
|
value={<Badge label={suggested.category} colour="var(--color-brand)" />}
|
||||||
size="sm"
|
|
||||||
options={categoryOptions}
|
|
||||||
value={categoryid}
|
|
||||||
onChange={(value) => onCategoryChange?.(value)}
|
|
||||||
placeholder="Leave uncategorised"
|
|
||||||
description="Your own category, not the catalogue's. Uncategorised beats wrongly categorised."
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
{/* The heading a shopper actually reads. The catalogue's 31
|
||||||
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
|
categories are finer than the app's ten aisles, so the two are
|
||||||
Adds this product with no price. It reaches no shop and cannot be sold until you
|
shown side by side rather than one standing in for the other —
|
||||||
price and publish it.
|
and a product with no aisle is told so here, not discovered
|
||||||
</Text>
|
missing from the app later. */}
|
||||||
<Button
|
<Row
|
||||||
label={isBusy ? 'Adding…' : actionLabel}
|
label="Shown in the app under"
|
||||||
variant="primary"
|
value={
|
||||||
icon={<DownloadCloud size={14} />}
|
aisle ? (
|
||||||
width="full"
|
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
|
||||||
isDisabled={Boolean(isBusy)}
|
) : (
|
||||||
onClick={onImport}
|
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</VStack>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
|
||||||
</Card>
|
<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 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.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DrawerCard>
|
||||||
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Gallery mode, driven by the same index the thumbnails set — so
|
{/* Gallery mode, driven by the same index the thumbnails set — so
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { VStack } from '@astryxdesign/core/VStack';
|
|||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import type { CatalogueBrand } from '@/api/types';
|
import type { CatalogueBrand } from '@/api/types';
|
||||||
|
|
||||||
const COLLAPSED_COUNT = 8;
|
const COLLAPSED_COUNT = 25;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The brand rail.
|
* The brand rail.
|
||||||
@@ -28,6 +28,7 @@ export function CatalogueSidebar({
|
|||||||
category,
|
category,
|
||||||
categories,
|
categories,
|
||||||
isLoadingCategories,
|
isLoadingCategories,
|
||||||
|
totalCount,
|
||||||
onBrand,
|
onBrand,
|
||||||
onCategory,
|
onCategory,
|
||||||
}: {
|
}: {
|
||||||
@@ -35,8 +36,26 @@ export function CatalogueSidebar({
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
brand: string;
|
brand: string;
|
||||||
category: 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;
|
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;
|
onBrand: (brand: string) => void;
|
||||||
onCategory: (category: string) => void;
|
onCategory: (category: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -56,7 +75,9 @@ export function CatalogueSidebar({
|
|||||||
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
|
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
|
||||||
All brands
|
All brands
|
||||||
<span className="rail-count">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -105,15 +126,15 @@ export function CatalogueSidebar({
|
|||||||
>
|
>
|
||||||
All categories
|
All categories
|
||||||
</button>
|
</button>
|
||||||
{categories.map((name) => (
|
{categories.map((entry) => (
|
||||||
<button
|
<button
|
||||||
key={name}
|
key={entry.value}
|
||||||
type="button"
|
type="button"
|
||||||
className="rail-category"
|
className="rail-category"
|
||||||
data-active={category === name}
|
data-active={category === entry.value}
|
||||||
onClick={() => onCategory(category === name ? '' : name)}
|
onClick={() => onCategory(category === entry.value ? '' : entry.value)}
|
||||||
>
|
>
|
||||||
{name}
|
{entry.label}
|
||||||
</button>
|
</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();
|
||||||
|
}
|
||||||
25
src/features/catalogue/tenantCategories.ts
Normal file
25
src/features/catalogue/tenantCategories.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* The category the customer app browses.
|
||||||
|
*
|
||||||
|
* A constant because it is one on the app side too — its browse screen asks for
|
||||||
|
* `categoryid: 2` — and because a product filed anywhere else is invisible to
|
||||||
|
* shoppers rather than merely misfiled.
|
||||||
|
*
|
||||||
|
* Measured, not assumed: every tenant on the platform that has products reports
|
||||||
|
* category 2 and nothing else, and category 2 carries the ten real retail
|
||||||
|
* subcategories.
|
||||||
|
*
|
||||||
|
* It survives as a LAST RESORT only. Nothing picks a category any longer: every
|
||||||
|
* product is classified by `productCategory.ts` and the name exchanged for this
|
||||||
|
* merchant's id by `resolvecategories`. This is what a row falls back to when
|
||||||
|
* that call cannot be made at all — better a product on the shelf under the
|
||||||
|
* aisle the app browses than a product filed under 0, which no browse query
|
||||||
|
* returns.
|
||||||
|
*
|
||||||
|
* What used to live beside it, `categoryOptionsFor`, is gone with the pickers
|
||||||
|
* it fed. It offered a tenant's own categories and this constant as a floor,
|
||||||
|
* and its floor was the visible symptom: a merchant with no list yet was shown
|
||||||
|
* "General (the category the app shows)", chose it because it was the only
|
||||||
|
* entry, and every product they imported arrived as "Category 2".
|
||||||
|
*/
|
||||||
|
export const APP_BROWSE_CATEGORY = 2;
|
||||||
32
src/features/console/AssistantScope.tsx
Normal file
32
src/features/console/AssistantScope.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { AssistantScopeContext } from '@/components/shell/assistantScope';
|
||||||
|
import { useBranchScope } from '@/features/store-admin/BranchScope';
|
||||||
|
import { consoleScope } from './consoleScope';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes the branch in view to Nearle Buddy.
|
||||||
|
*
|
||||||
|
* It has to sit here — inside `BranchScopeProvider` and OUTSIDE `AppShell` —
|
||||||
|
* because Buddy is rendered by the shell, which is a parent of the page. React
|
||||||
|
* context flows down, so a provider inside the Console could never reach the
|
||||||
|
* panel above it. That was the first attempt, and the panel went on announcing
|
||||||
|
* "Across your branches" over a board showing one shop.
|
||||||
|
*
|
||||||
|
* Renders only its children, so its position in the tree costs nothing.
|
||||||
|
*/
|
||||||
|
export function AssistantScope({ children }: { children: ReactNode }) {
|
||||||
|
const { branches, selected, current, isPinned } = useBranchScope();
|
||||||
|
|
||||||
|
const scope = consoleScope({
|
||||||
|
role: isPinned ? 'store-user' : 'admin',
|
||||||
|
selected,
|
||||||
|
branchName: current?.locationname,
|
||||||
|
branchCount: branches.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantScopeContext.Provider value={scope.buddyContext}>
|
||||||
|
{children}
|
||||||
|
</AssistantScopeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/features/console/ConsolePage.tsx
Normal file
181
src/features/console/ConsolePage.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
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,
|
||||||
|
useOrders,
|
||||||
|
usePosHealthByBranch,
|
||||||
|
usePosSalesByBranch,
|
||||||
|
useStockRequests,
|
||||||
|
} from '@/queries/hooks';
|
||||||
|
import { buildAlerts, totalsOf, type BranchRow } from './consoleModel';
|
||||||
|
import { consoleScope } from './consoleScope';
|
||||||
|
import { useConsoleSeries } from './useConsoleSeries';
|
||||||
|
import { BranchOverview } from './sections/BranchOverview';
|
||||||
|
import { KpiStrip } from './sections/KpiStrip';
|
||||||
|
import { NeedsAttention } from './sections/NeedsAttention';
|
||||||
|
import { SalesOverview } from './sections/SalesOverview';
|
||||||
|
import { StoreHealth } from './sections/StoreHealth';
|
||||||
|
import { TillSync } from './sections/TillSync';
|
||||||
|
import { QuickActions } from './sections/QuickActions';
|
||||||
|
import './console.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Console, three situations.
|
||||||
|
*
|
||||||
|
* An admin across every branch, an admin looking at one, and a store user who
|
||||||
|
* has exactly one. They differ in wording and in which sections appear — never
|
||||||
|
* in which component renders — so there is no second implementation to keep in
|
||||||
|
* step. `consoleScope` decides those differences as data; this file lays them
|
||||||
|
* out.
|
||||||
|
*
|
||||||
|
* ── Where the scope comes from ──────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* `useBranchScope` alone. It already pins a store user to their own outlet and
|
||||||
|
* ignores the URL parameter for them, so this page never asks who the user is
|
||||||
|
* in order to decide what to fetch — it asks what is in scope and fetches that.
|
||||||
|
* Re-deriving the scope here would be a second place for the two to disagree.
|
||||||
|
*
|
||||||
|
* ── The order of the sections ───────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* How much did we take, how did it arrive and is the shop working, which
|
||||||
|
* branch, what the counter is doing, what to do now. Money first because it is
|
||||||
|
* what the page is opened for; the thing to act on last because a merchant
|
||||||
|
* reads down and should finish on a verb.
|
||||||
|
*/
|
||||||
|
export function ConsolePage() {
|
||||||
|
const { branches, scoped, selected, current, tenantid, isLoading, isPinned, select } =
|
||||||
|
useBranchScope();
|
||||||
|
|
||||||
|
const base = isPinned ? '/store' : '/admin';
|
||||||
|
const dates = useDateScope();
|
||||||
|
|
||||||
|
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
|
||||||
|
/* 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, {
|
||||||
|
allBranches: true,
|
||||||
|
});
|
||||||
|
const requests = useStockRequests(
|
||||||
|
tenantid ? { tenantid, locationid: selected ?? undefined, status: 'Pending' } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const posNowData = useMemo(
|
||||||
|
() => posNow.flatMap((query) => (query.data ? [query.data] : [])),
|
||||||
|
[posNow],
|
||||||
|
);
|
||||||
|
const series = useConsoleSeries(tenantid, selected, dates.range, posNowData);
|
||||||
|
|
||||||
|
// One instant for the whole board, so two tills read a second apart are not
|
||||||
|
// judged against two different "nows".
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const rows = useMemo<BranchRow[]>(
|
||||||
|
() =>
|
||||||
|
scoped.map((branch, index) => {
|
||||||
|
const order = byBranch.get(branch.locationid) ?? NO_ORDERS;
|
||||||
|
const pos = posNow[index]?.data;
|
||||||
|
return {
|
||||||
|
branch,
|
||||||
|
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, byBranch, posNow, posHealth, requests.data, now],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const summed = totalsOf(rows);
|
||||||
|
// The dated figures win where they exist: a board headed "Sep 3" must not
|
||||||
|
// show an all-time online total beside a one-day counter total.
|
||||||
|
return {
|
||||||
|
...summed,
|
||||||
|
onlineRevenue: series.onlineRevenue,
|
||||||
|
onlineOrders: series.onlineOrders,
|
||||||
|
cancelled: series.cancelled,
|
||||||
|
totalRevenue: series.onlineRevenue + summed.counterRevenue,
|
||||||
|
totalOrders: series.onlineOrders + summed.counterBills,
|
||||||
|
};
|
||||||
|
}, [rows, series]);
|
||||||
|
|
||||||
|
const alerts = useMemo(() => buildAlerts(rows, base), [rows, base]);
|
||||||
|
|
||||||
|
const scope = consoleScope({
|
||||||
|
role: isPinned ? 'store-user' : 'admin',
|
||||||
|
selected,
|
||||||
|
branchName: current?.locationname,
|
||||||
|
branchCount: branches.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBusy =
|
||||||
|
isLoading ||
|
||||||
|
posNow.some((query) => query.isLoading) ||
|
||||||
|
posHealth.some((query) => query.isLoading) ||
|
||||||
|
series.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="console">
|
||||||
|
<h1 className="sr-only">Console</h1>
|
||||||
|
|
||||||
|
<KpiStrip totals={totals} isLoading={isBusy} />
|
||||||
|
|
||||||
|
{/* Full width. A trend needs the page: at two thirds it showed six days
|
||||||
|
before scrolling, which is not a trend, it is a sample. */}
|
||||||
|
<SalesOverview days={series.days} isLoading={isBusy} />
|
||||||
|
|
||||||
|
{/* Store health takes the slot the shop card had. The shop’s name and
|
||||||
|
address were the least useful thing on the board — a merchant knows
|
||||||
|
which shop they are in — and the health panel earns the width. */}
|
||||||
|
<div className="panel-pair">
|
||||||
|
<StoreHealth
|
||||||
|
rows={rows}
|
||||||
|
totals={totals}
|
||||||
|
isAggregate={scope.isAggregate}
|
||||||
|
productCount={(products.data ?? []).length}
|
||||||
|
base={base}
|
||||||
|
/>
|
||||||
|
<QuickActions base={base} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scope.showBranchOverview ? (
|
||||||
|
<section className="panel">
|
||||||
|
<header className="panel-head">
|
||||||
|
<div>
|
||||||
|
<h3 className="panel-title">Branch performance</h3>
|
||||||
|
<p className="panel-sub">
|
||||||
|
Ordered by takings — open one to scope the whole board to it
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<BranchOverview rows={rows} onSelect={select} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TillSync rows={rows} showBranch={scope.showBranchColumn} base={base} />
|
||||||
|
|
||||||
|
<NeedsAttention alerts={alerts} isAggregate={scope.isAggregate} base={base} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
579
src/features/console/console.css
Normal file
579
src/features/console/console.css
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
/* ══ Console ═══════════════════════════════════════════════════════════════
|
||||||
|
The board a shopkeeper opens twenty times a day.
|
||||||
|
|
||||||
|
Kept beside the feature rather than in `index.css`, which had grown to 1,700
|
||||||
|
lines of everything. Built entirely from the existing tokens: brand purple
|
||||||
|
carries action and identity, and semantic green / amber / red are reserved
|
||||||
|
for state — so a colour never means two things on one screen. */
|
||||||
|
|
||||||
|
.console { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||||
|
.console-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.console-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.console-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font: 600 26px/1.2 var(--font-display), Georgia, serif;
|
||||||
|
color: var(--color-ink-1);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.console-scope { font: 500 14px/1 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
.console-live {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font: 500 12.5px/1 var(--font-sans); color: var(--color-success, #1c6b47);
|
||||||
|
}
|
||||||
|
/* A pulse, not a blink: it says "reading" without demanding attention. */
|
||||||
|
.console-live i {
|
||||||
|
width: 7px; height: 7px; border-radius: 999px; background: currentColor;
|
||||||
|
animation: live-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes live-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||||
|
.console-blurb { margin: 6px 0 0; font: 400 13.5px/1.5 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
|
||||||
|
.console-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.health-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 12px; border-radius: 12px; text-decoration: none;
|
||||||
|
border: 1px solid var(--color-line); background: var(--color-surface);
|
||||||
|
font: 600 13px/1.3 var(--font-sans);
|
||||||
|
}
|
||||||
|
.health-pill span { display: flex; flex-direction: column; line-height: 1.25; }
|
||||||
|
.health-pill b { font: 500 11px/1.3 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
.health-pill[data-tone="healthy"] { border-color: #bfe3cf; background: #eef8f2; color: #1c6b47; }
|
||||||
|
.health-pill[data-tone="attention"] { border-color: #ecd9a8; background: #fdf6e8; color: #8a5a00; }
|
||||||
|
.health-pill[data-tone="critical"] { border-color: #f0c4c0; background: #fdefee; color: #b3261e; }
|
||||||
|
.health-pill[data-tone="healthy"] b,
|
||||||
|
.health-pill[data-tone="attention"] b,
|
||||||
|
.health-pill[data-tone="critical"] b { color: inherit; opacity: .75; }
|
||||||
|
|
||||||
|
/* ── KPI cards ──────────────────────────────────────────────────────────── */
|
||||||
|
.kpi-strip {
|
||||||
|
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(238px, 1fr);
|
||||||
|
gap: 14px; overflow-x: auto; padding-bottom: 4px; scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
.kpi-strip > * { scroll-snap-align: start; }
|
||||||
|
@media (min-width: 1180px) {
|
||||||
|
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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: 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-foot {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
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;
|
||||||
|
border: 1px dashed var(--color-line); border-radius: 12px;
|
||||||
|
}
|
||||||
|
.panel-empty strong { font: 600 14px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.panel-empty span { font: 400 13px/1.5 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
|
||||||
|
/* ── Shared: tiles, pills, buttons ──────────────────────────────────────── */
|
||||||
|
.tile {
|
||||||
|
width: 34px; height: 34px; flex: none; border-radius: 10px; display: grid; place-items: center;
|
||||||
|
background: var(--color-surface-subtle); color: var(--color-ink-2);
|
||||||
|
}
|
||||||
|
.tile[data-tone="brand"] { background: var(--color-brand-tint); color: var(--color-brand); }
|
||||||
|
.tile[data-tone="healthy"] { background: #eef8f2; color: #1c6b47; }
|
||||||
|
.tile[data-tone="attention"] { background: #fdf6e8; color: #8a5a00; }
|
||||||
|
.tile[data-tone="critical"] { background: #fdefee; color: #b3261e; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
|
||||||
|
border-radius: 999px; white-space: nowrap; font: 500 11.5px/1 var(--font-sans);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.pill i { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
|
||||||
|
.pill[data-tone="healthy"] { background: #eef8f2; color: #1c6b47; border-color: #cbe8d8; }
|
||||||
|
.pill[data-tone="attention"] { background: #fdf6e8; color: #8a5a00; border-color: #eeddb4; }
|
||||||
|
.pill[data-tone="critical"] { background: #fdefee; color: #b3261e; border-color: #f2cbc7; }
|
||||||
|
|
||||||
|
.btn-outline, .btn-solid {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
height: 30px; padding: 0 12px; border-radius: 9px; white-space: nowrap;
|
||||||
|
font: 500 12.5px/1 var(--font-sans); text-decoration: none;
|
||||||
|
border: 1px solid var(--color-line); background: var(--color-surface); color: var(--color-ink-1);
|
||||||
|
}
|
||||||
|
.btn-outline:hover { border-color: var(--color-brand); color: var(--color-brand); }
|
||||||
|
.btn-solid { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
|
||||||
|
.btn-solid[data-tone="critical"] { background: #b3261e; border-color: #b3261e; }
|
||||||
|
.btn-solid[data-tone="attention"] { background: #8a5a00; border-color: #8a5a00; }
|
||||||
|
.btn-outline:focus-visible, .btn-solid:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.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 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.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: var(--card-border); border-radius: var(--card-radius-sm);
|
||||||
|
background: var(--card-bg-subtle);
|
||||||
|
}
|
||||||
|
.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. 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-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 ───────────────────────────────────────────────────────── */
|
||||||
|
.health-list { display: flex; flex-direction: column; }
|
||||||
|
.health-line {
|
||||||
|
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
gap: 12px; align-items: center; padding: 11px 2px;
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.health-list > .health-line:last-child { border-bottom: none; }
|
||||||
|
.health-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.health-name { font: 600 13.5px/1.2 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.health-detail { font: 400 12px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Quick actions ──────────────────────────────────────────────────────── */
|
||||||
|
/* Store health beside quick actions. Health carries four rows of detail and
|
||||||
|
earns the larger share; the actions are three doors. */
|
||||||
|
.panel-pair { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
|
||||||
|
@media (min-width: 900px) { .panel-pair { grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); } }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.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: 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); }
|
||||||
|
.quick:hover { border-color: var(--color-brand); background: var(--color-brand-tint); }
|
||||||
|
.quick-label { font: 500 13px/1.2 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.quick-chev { color: var(--color-ink-4); }
|
||||||
|
|
||||||
|
/* ── Tables ─────────────────────────────────────────────────────────────── */
|
||||||
|
.grid-table, .branch-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 680px; }
|
||||||
|
.grid-table th, .grid-table td, .branch-table th, .branch-table td {
|
||||||
|
text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--color-line); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.grid-table th, .branch-table th {
|
||||||
|
font: 500 10.5px/1 var(--font-sans); letter-spacing: .07em; text-transform: uppercase;
|
||||||
|
color: var(--color-ink-3); background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
.grid-table tbody tr:last-child td, .branch-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.grid-table .num, .branch-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.grid-table tbody tr:hover, .branch-table tbody tr:hover { background: var(--color-surface-subtle); }
|
||||||
|
|
||||||
|
.heartbeat { display: inline-flex; align-items: center; gap: 7px; color: var(--color-ink-2); }
|
||||||
|
.heartbeat i { width: 6px; height: 6px; border-radius: 999px; }
|
||||||
|
.heartbeat[data-tone="healthy"] i { background: #1c6b47; }
|
||||||
|
.heartbeat[data-tone="attention"] i { background: #8a5a00; }
|
||||||
|
.heartbeat[data-tone="critical"] i { background: #b3261e; }
|
||||||
|
|
||||||
|
.till-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
/* `.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.
|
||||||
|
|
||||||
|
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. */
|
||||||
|
|
||||||
|
/* `.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; }
|
||||||
|
.attention-clear { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.attention-clear div { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.attention-clear strong { font: 600 14px/1.2 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.attention-clear span { font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
|
||||||
|
.attention-grid { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); }
|
||||||
|
@media (min-width: 860px) { .attention-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
|
||||||
|
.attention-card {
|
||||||
|
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
|
||||||
|
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; }
|
||||||
|
.attention-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.attention-title { font: 400 13px/1.3 var(--font-sans); color: var(--color-ink-1); }
|
||||||
|
.attention-detail { font: 400 12px/1.45 var(--font-sans); color: var(--color-ink-3); }
|
||||||
|
.sev { padding: 3px 9px; border-radius: 999px; font: 500 11px/1 var(--font-sans); white-space: nowrap; }
|
||||||
|
.sev[data-tone="critical"] { background: #fadad6; color: #b3261e; }
|
||||||
|
.sev[data-tone="attention"] { background: #f8e9c8; color: #8a5a00; }
|
||||||
|
|
||||||
|
/* The action drops beneath rather than squeezing the text to two words. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.attention-card, .health-line { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.attention-card > :nth-child(3), .attention-card > :nth-child(4),
|
||||||
|
.health-line > :nth-child(3), .health-line > :nth-child(4) { grid-column: 2; justify-self: start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ────────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
|
||||||
|
.sk-card, .sk-chart {
|
||||||
|
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` 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. */
|
||||||
|
}
|
||||||
120
src/features/console/consoleModel.test.ts
Normal file
120
src/features/console/consoleModel.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type { TenantLocation } from '@/api/types';
|
||||||
|
import type { BranchSyncSummary, TerminalState } from '@/features/store-admin/posStatus';
|
||||||
|
import { branchTone, buildAlerts, totalsOf, type BranchRow } from './consoleModel';
|
||||||
|
|
||||||
|
const health = (over: Partial<BranchSyncSummary> = {}): BranchSyncSummary => ({
|
||||||
|
state: 'synced' as TerminalState,
|
||||||
|
online: 2,
|
||||||
|
total: 2,
|
||||||
|
pendingBills: 0,
|
||||||
|
oldestPendingMs: 0,
|
||||||
|
lastBillAt: null,
|
||||||
|
terminals: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = (id: number, over: Partial<BranchRow> = {}): BranchRow => ({
|
||||||
|
branch: { locationid: id, locationname: `Branch ${id}` } as TenantLocation,
|
||||||
|
onlineRevenue: 0, onlineOrders: 0, cancelled: 0, delivered: 0,
|
||||||
|
counterRevenue: 0, counterBills: 0, pendingRequests: 0,
|
||||||
|
health: health(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Totals ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('the aggregate is the sum of exactly the branches in scope', () => {
|
||||||
|
// The honesty of "All branches" rests on this: the figure shown is the sum of
|
||||||
|
// the rows listed under it, not a separate query with its own idea of total.
|
||||||
|
const totals = totalsOf([
|
||||||
|
row(1, { onlineRevenue: 100, counterRevenue: 50, onlineOrders: 4, counterBills: 3 }),
|
||||||
|
row(2, { onlineRevenue: 200, counterRevenue: 25, onlineOrders: 6, counterBills: 1 }),
|
||||||
|
]);
|
||||||
|
assert.equal(totals.onlineRevenue, 300);
|
||||||
|
assert.equal(totals.counterRevenue, 75);
|
||||||
|
assert.equal(totals.totalRevenue, 375);
|
||||||
|
assert.equal(totals.totalOrders, 14);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one branch in scope totals to that branch', () => {
|
||||||
|
const totals = totalsOf([row(1, { onlineRevenue: 100, counterRevenue: 50 })]);
|
||||||
|
assert.equal(totals.totalRevenue, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no branches is zero, not a crash or a blank', () => {
|
||||||
|
const totals = totalsOf([]);
|
||||||
|
assert.equal(totals.totalRevenue, 0);
|
||||||
|
assert.equal(totals.tillsTotal, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Health ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('every till down is critical, not merely attention', () => {
|
||||||
|
assert.equal(branchTone(health({ online: 0, total: 3 })), 'critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('some tills down is attention', () => {
|
||||||
|
assert.equal(branchTone(health({ online: 2, total: 3 })), 'attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale till outranks a delayed one', () => {
|
||||||
|
// Its last word was "fine" and everything since is guesswork.
|
||||||
|
assert.equal(branchTone(health({ state: 'stale' })), 'critical');
|
||||||
|
assert.equal(branchTone(health({ state: 'delayed' })), 'attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a branch with no tills is not reported as healthy', () => {
|
||||||
|
// Silence is not health — nothing is being counted at that counter.
|
||||||
|
assert.equal(branchTone(health({ online: 0, total: 0 })), 'attention');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Alerts ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
test('a healthy shop raises nothing at all', () => {
|
||||||
|
// The empty state is a real outcome, not a gap to fill with reassurance.
|
||||||
|
assert.deepEqual(buildAlerts([row(1)], '/admin'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the worst thing is first', () => {
|
||||||
|
const alerts = buildAlerts(
|
||||||
|
[row(1, { pendingRequests: 2 }), row(2, { health: health({ online: 0, total: 2 }) })],
|
||||||
|
'/admin',
|
||||||
|
);
|
||||||
|
assert.equal(alerts[0]?.severity, 'critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every alert names its branch, even on a single-branch board', () => {
|
||||||
|
// An alert someone screenshots and forwards should say where it came from.
|
||||||
|
for (const alert of buildAlerts([row(7, { pendingRequests: 1 })], '/store')) {
|
||||||
|
assert.equal(alert.branch, 'Branch 7');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsynced bills say why they matter, not just that they exist', () => {
|
||||||
|
const alert = buildAlerts([row(1, { health: health({ pendingBills: 3 }) })], '/admin')[0];
|
||||||
|
assert.match(String(alert?.detail), /revenue reads low/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a large unsynced queue is escalated', () => {
|
||||||
|
const small = buildAlerts([row(1, { health: health({ pendingBills: 3 }) })], '/admin')[0];
|
||||||
|
const large = buildAlerts([row(1, { health: health({ pendingBills: 40 }) })], '/admin')[0];
|
||||||
|
assert.equal(small?.severity, 'attention');
|
||||||
|
assert.equal(large?.severity, 'critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancellations are only raised when they are a pattern', () => {
|
||||||
|
// One cancelled order in fifty is a shopper changing their mind.
|
||||||
|
const rare = buildAlerts([row(1, { onlineOrders: 50, cancelled: 1 })], '/admin');
|
||||||
|
assert.equal(rare.length, 0);
|
||||||
|
const heavy = buildAlerts([row(1, { onlineOrders: 10, cancelled: 3 })], '/admin');
|
||||||
|
assert.equal(heavy.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alerts link into the workspace the reader is already in', () => {
|
||||||
|
// A store user must not be sent to /admin by an alert.
|
||||||
|
for (const alert of buildAlerts([row(1, { pendingRequests: 1 })], '/store')) {
|
||||||
|
assert.ok(alert.href.startsWith('/store'), alert.href);
|
||||||
|
}
|
||||||
|
});
|
||||||
176
src/features/console/consoleModel.ts
Normal file
176
src/features/console/consoleModel.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type { TenantLocation } from '@/api/types';
|
||||||
|
import type { BranchSyncSummary } from '@/features/store-admin/posStatus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board's arithmetic, separated from its layout.
|
||||||
|
*
|
||||||
|
* Every number on the Console is derived from the same per-branch rows, and the
|
||||||
|
* aggregate is a sum of exactly the rows in scope — never a separate query with
|
||||||
|
* its own idea of the total. That is what makes "All branches" honest: the sum
|
||||||
|
* shown is the sum of the branches listed under it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BranchRow {
|
||||||
|
branch: TenantLocation;
|
||||||
|
onlineRevenue: number;
|
||||||
|
onlineOrders: number;
|
||||||
|
cancelled: number;
|
||||||
|
delivered: number;
|
||||||
|
counterRevenue: number;
|
||||||
|
counterBills: number;
|
||||||
|
health: BranchSyncSummary;
|
||||||
|
pendingRequests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleTotals {
|
||||||
|
onlineRevenue: number;
|
||||||
|
onlineOrders: number;
|
||||||
|
cancelled: number;
|
||||||
|
counterRevenue: number;
|
||||||
|
counterBills: number;
|
||||||
|
pendingBills: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
totalOrders: number;
|
||||||
|
tillsOnline: number;
|
||||||
|
tillsTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalsOf(rows: readonly BranchRow[]): ConsoleTotals {
|
||||||
|
const totals = rows.reduce<ConsoleTotals>(
|
||||||
|
(acc, row) => ({
|
||||||
|
onlineRevenue: acc.onlineRevenue + row.onlineRevenue,
|
||||||
|
onlineOrders: acc.onlineOrders + row.onlineOrders,
|
||||||
|
cancelled: acc.cancelled + row.cancelled,
|
||||||
|
counterRevenue: acc.counterRevenue + row.counterRevenue,
|
||||||
|
counterBills: acc.counterBills + row.counterBills,
|
||||||
|
pendingBills: acc.pendingBills + row.health.pendingBills,
|
||||||
|
totalRevenue: 0,
|
||||||
|
totalOrders: 0,
|
||||||
|
tillsOnline: acc.tillsOnline + row.health.online,
|
||||||
|
tillsTotal: acc.tillsTotal + row.health.total,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onlineRevenue: 0, onlineOrders: 0, cancelled: 0, counterRevenue: 0,
|
||||||
|
counterBills: 0, pendingBills: 0, totalRevenue: 0, totalOrders: 0,
|
||||||
|
tillsOnline: 0, tillsTotal: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived last so the two channels cannot disagree with their own sum.
|
||||||
|
totals.totalRevenue = totals.onlineRevenue + totals.counterRevenue;
|
||||||
|
totals.totalOrders = totals.onlineOrders + totals.counterBills;
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Health ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type HealthTone = 'healthy' | 'attention' | 'critical';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one branch is doing, in three words rather than six states.
|
||||||
|
*
|
||||||
|
* `posStatus` distinguishes synced/syncing/delayed/stale/offline because a till
|
||||||
|
* screen needs that much detail. A dashboard does not: the question here is
|
||||||
|
* whether somebody has to go and do something, so the states collapse to fine,
|
||||||
|
* look at it, or it is down.
|
||||||
|
*/
|
||||||
|
export function branchTone(health: BranchSyncSummary): HealthTone {
|
||||||
|
if (health.total === 0) return 'attention';
|
||||||
|
if (health.online === 0) return 'critical';
|
||||||
|
if (health.state === 'stale' || health.state === 'offline') return 'critical';
|
||||||
|
if (health.state === 'delayed' || health.online < health.total) return 'attention';
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attention ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface ConsoleAlert {
|
||||||
|
id: string;
|
||||||
|
severity: HealthTone;
|
||||||
|
branch: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
actionLabel: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What somebody has to do something about, worst first.
|
||||||
|
*
|
||||||
|
* Only real conditions, and each one names the branch even on a single-branch
|
||||||
|
* board — an alert a merchant might screenshot and send to a colleague should
|
||||||
|
* say where it came from.
|
||||||
|
*
|
||||||
|
* Deliberately not "every anomaly". A dashboard that lists thirty warnings
|
||||||
|
* teaches people to scroll past the section, and then the one that mattered is
|
||||||
|
* scrolled past too.
|
||||||
|
*/
|
||||||
|
export function buildAlerts(rows: readonly BranchRow[], base: string): ConsoleAlert[] {
|
||||||
|
const alerts: ConsoleAlert[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = row.branch.locationname?.trim() || `Branch ${row.branch.locationid}`;
|
||||||
|
const health = row.health;
|
||||||
|
|
||||||
|
if (health.total > 0 && health.online === 0) {
|
||||||
|
alerts.push({
|
||||||
|
id: `tills-down-${row.branch.locationid}`,
|
||||||
|
severity: 'critical',
|
||||||
|
branch: name,
|
||||||
|
title: health.total === 1 ? 'Till not reporting' : `${health.total} tills not reporting`,
|
||||||
|
detail: 'No heartbeat, so counter sales are not reaching the books.',
|
||||||
|
actionLabel: 'Check tills',
|
||||||
|
href: `${base}/terminals`,
|
||||||
|
});
|
||||||
|
} else if (health.online < health.total) {
|
||||||
|
alerts.push({
|
||||||
|
id: `tills-part-${row.branch.locationid}`,
|
||||||
|
severity: 'attention',
|
||||||
|
branch: name,
|
||||||
|
title: `${health.total - health.online} of ${health.total} tills offline`,
|
||||||
|
detail: 'The rest are reporting normally.',
|
||||||
|
actionLabel: 'View tills',
|
||||||
|
href: `${base}/terminals`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.pendingBills > 0) {
|
||||||
|
alerts.push({
|
||||||
|
id: `bills-${row.branch.locationid}`,
|
||||||
|
severity: health.pendingBills > 10 ? 'critical' : 'attention',
|
||||||
|
branch: name,
|
||||||
|
title: `${health.pendingBills} bill${health.pendingBills === 1 ? '' : 's'} not synced`,
|
||||||
|
detail: 'Taken at the counter and not yet in the books, so revenue reads low.',
|
||||||
|
actionLabel: 'View tills',
|
||||||
|
href: `${base}/terminals`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.pendingRequests > 0) {
|
||||||
|
alerts.push({
|
||||||
|
id: `requests-${row.branch.locationid}`,
|
||||||
|
severity: 'attention',
|
||||||
|
branch: name,
|
||||||
|
title: `${row.pendingRequests} stock request${row.pendingRequests === 1 ? '' : 's'} waiting`,
|
||||||
|
detail: 'Nothing reaches a shelf until these are decided.',
|
||||||
|
actionLabel: 'Review',
|
||||||
|
href: `${base}/inventory?tab=requests`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.cancelled > 0 && row.onlineOrders > 0 && row.cancelled / row.onlineOrders >= 0.2) {
|
||||||
|
alerts.push({
|
||||||
|
id: `cancels-${row.branch.locationid}`,
|
||||||
|
severity: 'attention',
|
||||||
|
branch: name,
|
||||||
|
title: `${Math.round((row.cancelled / row.onlineOrders) * 100)}% of orders cancelled`,
|
||||||
|
detail: `${row.cancelled} of ${row.onlineOrders} app orders did not complete.`,
|
||||||
|
actionLabel: 'View sales',
|
||||||
|
href: `${base}/sales`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const order: Record<HealthTone, number> = { critical: 0, attention: 1, healthy: 2 };
|
||||||
|
return alerts.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||||
|
}
|
||||||
67
src/features/console/consoleScope.test.ts
Normal file
67
src/features/console/consoleScope.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { consoleScope } from './consoleScope';
|
||||||
|
|
||||||
|
/*
|
||||||
|
The rules a later refactor is most likely to break are the ones about what a
|
||||||
|
store user may see. They are asserted here rather than left to be noticed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const admin = { role: 'admin' as const, branchCount: 13 };
|
||||||
|
const storeUser = { role: 'store-user' as const, branchCount: 1 };
|
||||||
|
|
||||||
|
test('an admin across branches is told the numbers are aggregated', () => {
|
||||||
|
const scope = consoleScope({ ...admin, selected: null });
|
||||||
|
assert.equal(scope.countLabel, '13 branches');
|
||||||
|
assert.equal(scope.isAggregate, true);
|
||||||
|
assert.match(scope.blurb, /across your branches/);
|
||||||
|
assert.equal(scope.buddyContext, 'Across your branches');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one branch is never described as many', () => {
|
||||||
|
const scope = consoleScope({ role: 'admin', branchCount: 1, selected: null });
|
||||||
|
assert.equal(scope.countLabel, '1 branch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choosing a branch narrows the header, the blurb and Buddy together', () => {
|
||||||
|
const scope = consoleScope({ ...admin, selected: 1185, branchName: 'R mart' });
|
||||||
|
assert.equal(scope.countLabel, 'R mart');
|
||||||
|
assert.equal(scope.blurb, 'Everything trading right now — for R mart.');
|
||||||
|
assert.equal(scope.buddyContext, 'R mart');
|
||||||
|
assert.equal(scope.isAggregate, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a store user never aggregates, even if the selection arrives null', () => {
|
||||||
|
// The frontend pins them, but a pin that fails open is the whole risk. This
|
||||||
|
// asserts the fallback is "your own store", never "everything".
|
||||||
|
const scope = consoleScope({ ...storeUser, selected: null });
|
||||||
|
assert.equal(scope.isAggregate, false);
|
||||||
|
assert.equal(scope.showBranchOverview, false);
|
||||||
|
assert.equal(scope.buddyContext, 'your store');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a store user is never offered the branch overview', () => {
|
||||||
|
for (const selected of [null, 1185]) {
|
||||||
|
const scope = consoleScope({ ...storeUser, selected, branchName: 'R mart' });
|
||||||
|
assert.equal(scope.showBranchOverview, false, String(selected));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shop section is not called "your shop" when many are in view', () => {
|
||||||
|
assert.equal(consoleScope({ ...admin, selected: null }).shopSectionTitle, 'Branch performance');
|
||||||
|
assert.equal(
|
||||||
|
consoleScope({ ...storeUser, selected: 1185, branchName: 'R mart' }).shopSectionTitle,
|
||||||
|
'Your shop',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a branch column only earns its width when branches are mixed', () => {
|
||||||
|
assert.equal(consoleScope({ ...admin, selected: null }).showBranchColumn, true);
|
||||||
|
assert.equal(consoleScope({ ...admin, selected: 1185 }).showBranchColumn, false);
|
||||||
|
assert.equal(consoleScope({ ...storeUser, selected: 1185 }).showBranchColumn, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank branch name does not produce a sentence with a hole in it', () => {
|
||||||
|
const scope = consoleScope({ ...admin, selected: 1185, branchName: ' ' });
|
||||||
|
assert.equal(scope.blurb, 'Everything trading right now — for your store.');
|
||||||
|
});
|
||||||
82
src/features/console/consoleScope.ts
Normal file
82
src/features/console/consoleScope.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* What this Console is showing, and to whom.
|
||||||
|
*
|
||||||
|
* One component serves three situations — an admin across every branch, an
|
||||||
|
* admin looking at one, and a store user who has exactly one. The differences
|
||||||
|
* are almost entirely in the words and in which sections appear, so they are
|
||||||
|
* decided here as data rather than scattered through the layout as `isPinned &&`
|
||||||
|
* conditions that drift apart.
|
||||||
|
*
|
||||||
|
* Pure on purpose. The rules about what a store user may see are the kind that
|
||||||
|
* get quietly broken by a later refactor, so they are tested rather than
|
||||||
|
* inspected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ConsoleRole = 'admin' | 'store-user';
|
||||||
|
|
||||||
|
export interface ConsoleScopeInput {
|
||||||
|
role: ConsoleRole;
|
||||||
|
/** The branch in view. `null` means every branch the user may see. */
|
||||||
|
selected: number | null;
|
||||||
|
/** Branch name when one is selected. */
|
||||||
|
branchName?: string | undefined;
|
||||||
|
/** How many branches this user is authorised for. */
|
||||||
|
branchCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsoleScope {
|
||||||
|
/** True only for an admin looking across more than their own single branch. */
|
||||||
|
isAggregate: boolean;
|
||||||
|
/** Sits beside the "Console" title. */
|
||||||
|
countLabel: string;
|
||||||
|
/** The sentence under the title. */
|
||||||
|
blurb: string;
|
||||||
|
/** What Nearle Buddy says it is answering about. */
|
||||||
|
buddyContext: string;
|
||||||
|
/** Admin-only, and only across branches. */
|
||||||
|
showBranchOverview: boolean;
|
||||||
|
/** "Your shop" reads as a lie when several are in view. */
|
||||||
|
shopSectionTitle: string;
|
||||||
|
/** Whether a per-branch column earns its width. */
|
||||||
|
showBranchColumn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consoleScope({
|
||||||
|
role,
|
||||||
|
selected,
|
||||||
|
branchName,
|
||||||
|
branchCount,
|
||||||
|
}: ConsoleScopeInput): ConsoleScope {
|
||||||
|
// A store user is pinned to one outlet, so "all branches" is not a state they
|
||||||
|
// can reach — and if a stray null ever arrives here it must not be read as
|
||||||
|
// permission to aggregate.
|
||||||
|
const isAggregate = role === 'admin' && selected === null;
|
||||||
|
|
||||||
|
if (isAggregate) {
|
||||||
|
return {
|
||||||
|
isAggregate: true,
|
||||||
|
countLabel: `${branchCount} ${branchCount === 1 ? 'branch' : 'branches'}`,
|
||||||
|
blurb: 'Everything trading right now — across your branches, and what needs your attention.',
|
||||||
|
buddyContext: 'Across your branches',
|
||||||
|
showBranchOverview: true,
|
||||||
|
// Naming it "Your shop" while thirteen are in view invites a merchant to
|
||||||
|
// read one shop's numbers as the whole business.
|
||||||
|
shopSectionTitle: 'Branch performance',
|
||||||
|
showBranchColumn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = branchName?.trim() || 'your store';
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAggregate: false,
|
||||||
|
countLabel: name,
|
||||||
|
blurb: `Everything trading right now — for ${name}.`,
|
||||||
|
buddyContext: name,
|
||||||
|
showBranchOverview: false,
|
||||||
|
shopSectionTitle: role === 'store-user' ? 'Your shop' : 'Branch',
|
||||||
|
// A store user already knows which branch they are in; an admin who chose
|
||||||
|
// one branch does too. Neither needs it repeated in every table row.
|
||||||
|
showBranchColumn: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
src/features/console/sections/BranchOverview.tsx
Normal file
105
src/features/console/sections/BranchOverview.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { branchLabel, count, money } from '@/features/store-admin/format';
|
||||||
|
import { branchTone, type BranchRow } from '../consoleModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which branches are doing well, and which want looking at.
|
||||||
|
*
|
||||||
|
* Admin-only and aggregate-only: a store user has one branch and an admin who
|
||||||
|
* has already chosen one is looking at it, so in both cases this is a table of
|
||||||
|
* one row restating the page around it.
|
||||||
|
*
|
||||||
|
* Sorted by takings, worst health first within that — the two questions a
|
||||||
|
* multi-branch owner actually opens this for are "who is selling" and "who is
|
||||||
|
* broken", and one ordering can serve both if health is on the row.
|
||||||
|
*
|
||||||
|
* "View branch" sets the branch selector rather than navigating somewhere new,
|
||||||
|
* so the whole Console becomes that branch's — which is the same thing the top
|
||||||
|
* navigation does, reached from the place the question was asked.
|
||||||
|
*/
|
||||||
|
export interface BranchOverviewProps {
|
||||||
|
rows: readonly BranchRow[];
|
||||||
|
onSelect: (locationid: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card padding={3}>
|
||||||
|
<Text type="body" size="sm" color="secondary">
|
||||||
|
No branches are set up yet.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = [...rows].sort(
|
||||||
|
(a, b) => b.onlineRevenue + b.counterRevenue - (a.onlineRevenue + a.counterRevenue),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding={0}>
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table className="branch-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th className="num">Online</th>
|
||||||
|
<th className="num">Counter</th>
|
||||||
|
<th className="num">Total</th>
|
||||||
|
<th className="num">Orders</th>
|
||||||
|
<th>Tills</th>
|
||||||
|
<th>Health</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ordered.map((row) => {
|
||||||
|
const tone = branchTone(row.health);
|
||||||
|
const total = row.onlineRevenue + row.counterRevenue;
|
||||||
|
return (
|
||||||
|
<tr key={row.branch.locationid}>
|
||||||
|
<td>
|
||||||
|
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||||
|
{branchLabel(row.branch.locationname) || `Branch ${row.branch.locationid}`}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td className="num">{money(row.onlineRevenue)}</td>
|
||||||
|
<td className="num">{money(row.counterRevenue)}</td>
|
||||||
|
<td className="num"><strong>{money(total)}</strong></td>
|
||||||
|
<td className="num">{count(row.onlineOrders + row.counterBills)}</td>
|
||||||
|
<td>
|
||||||
|
{row.health.total === 0
|
||||||
|
? '—'
|
||||||
|
: `${row.health.online}/${row.health.total}`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<HStack gap={1} align="center">
|
||||||
|
<span className="status-dot" data-tone={tone} aria-hidden />
|
||||||
|
<Text type="body" size="xsm" color="secondary">
|
||||||
|
{tone === 'healthy' ? 'Healthy' : tone === 'attention' ? 'Attention' : 'Offline'}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
label="View"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
endContent={<ArrowRight size={13} />}
|
||||||
|
onClick={() => onSelect(row.branch.locationid)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/features/console/sections/KpiStrip.tsx
Normal file
67
src/features/console/sections/KpiStrip.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five figures the board opens with.
|
||||||
|
*
|
||||||
|
* Icon, label, number. Nothing else.
|
||||||
|
*
|
||||||
|
* ── What used to be here, and why it went ───────────────────────────────────
|
||||||
|
*
|
||||||
|
* A "↑ 12% vs. yesterday" line and a sparkline on every card. Both removed on
|
||||||
|
* request, and the request was right: five cards each carrying a percentage, an
|
||||||
|
* arrow and a chart made the strip the busiest thing on a page whose job is to
|
||||||
|
* answer "how much did we take" at a glance. A trend belongs in Reports, where
|
||||||
|
* somebody has gone looking for one.
|
||||||
|
*
|
||||||
|
* The line that stayed is a plain statement of fact, not a comparison: 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.
|
||||||
|
*/
|
||||||
|
export interface KpiStripProps {
|
||||||
|
totals: ConsoleTotals;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStrip({ totals, isLoading }: KpiStripProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="kpi-strip" aria-busy="true">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="sk-card" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kpi-strip">
|
||||||
|
<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`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Ban size={17} />}
|
||||||
|
label="Cancelled Orders"
|
||||||
|
value={count(totals.cancelled)}
|
||||||
|
{...(totals.onlineOrders > 0
|
||||||
|
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<CloudUpload size={17} />}
|
||||||
|
label="Unsynced Bills"
|
||||||
|
value={count(totals.pendingBills)}
|
||||||
|
note={totals.pendingBills === 0 ? 'all bills are in the books' : 'waiting on a till'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
86
src/features/console/sections/NeedsAttention.tsx
Normal file
86
src/features/console/sections/NeedsAttention.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Bell, CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { ConsoleAlert } from '../consoleModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one section that exists to be acted on.
|
||||||
|
*
|
||||||
|
* Two to a row rather than a stack, so the whole list is visible without
|
||||||
|
* scrolling past it — an alert below the fold is an alert nobody acts on. Each
|
||||||
|
* card carries severity, what happened, why it matters, and the verb.
|
||||||
|
*
|
||||||
|
* Capped, and the cap is the point: thirteen branches with three problems each
|
||||||
|
* is thirty-nine cards, which is a list nobody reads to the end of. The worst
|
||||||
|
* are shown and the rest are counted, so "View all" is a real destination
|
||||||
|
* rather than a way of hiding the number.
|
||||||
|
*/
|
||||||
|
const SHOWN = 4;
|
||||||
|
|
||||||
|
export interface NeedsAttentionProps {
|
||||||
|
alerts: readonly ConsoleAlert[];
|
||||||
|
isAggregate: boolean;
|
||||||
|
base: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NeedsAttention({ alerts, isAggregate, base }: NeedsAttentionProps) {
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="panel attention-panel">
|
||||||
|
<div className="attention-clear">
|
||||||
|
<span className="tile" data-tone="healthy" aria-hidden><CheckCircle2 size={16} /></span>
|
||||||
|
<div>
|
||||||
|
<strong>You’re all caught up</strong>
|
||||||
|
<span>
|
||||||
|
{isAggregate
|
||||||
|
? 'No branch is reporting anything that needs you right now.'
|
||||||
|
: 'Nothing needs your attention right now.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = alerts.slice(0, SHOWN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel attention-panel">
|
||||||
|
<header className="panel-head">
|
||||||
|
<div className="attention-head">
|
||||||
|
<span className="tile" data-tone="brand" aria-hidden><Bell size={16} /></span>
|
||||||
|
<div>
|
||||||
|
<h3 className="panel-title">Needs your attention</h3>
|
||||||
|
<p className="panel-sub">
|
||||||
|
These items need your action to keep your store running smoothly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{alerts.length > SHOWN ? (
|
||||||
|
<Link to={`${base}/terminals`} className="link-quiet">
|
||||||
|
View all ({alerts.length})
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="attention-grid">
|
||||||
|
{shown.map((alert) => (
|
||||||
|
<article key={alert.id} className="attention-card" data-tone={alert.severity}>
|
||||||
|
<span className="tile" data-tone={alert.severity} aria-hidden><Bell size={15} /></span>
|
||||||
|
<div className="attention-text">
|
||||||
|
<span className="attention-title">
|
||||||
|
{alert.branch} — <strong>{alert.title}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="attention-detail">{alert.detail}</span>
|
||||||
|
</div>
|
||||||
|
<span className="sev" data-tone={alert.severity}>
|
||||||
|
{alert.severity === 'critical' ? 'High' : 'Medium'}
|
||||||
|
</span>
|
||||||
|
<Link to={alert.href} className="btn-solid" data-tone={alert.severity}>
|
||||||
|
{alert.actionLabel}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/features/console/sections/QuickActions.tsx
Normal file
61
src/features/console/sections/QuickActions.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Boxes, ChevronRight, Eye, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three things most often done from this board.
|
||||||
|
*
|
||||||
|
* Lifted out of the shop card when that card went. They belong beside Store
|
||||||
|
* health rather than under it: the health panel is where a merchant learns that
|
||||||
|
* something needs doing, and these are the doors to go and do it.
|
||||||
|
*
|
||||||
|
* Deliberately three. A column of nine shortcuts is a second navigation, and
|
||||||
|
* this console already has one along the top.
|
||||||
|
*/
|
||||||
|
export function QuickActions({ base }: { base: string }) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<header className="panel-head">
|
||||||
|
<div>
|
||||||
|
<h3 className="panel-title">Quick actions</h3>
|
||||||
|
<p className="panel-sub">The three things most often done from here</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="quick-list">
|
||||||
|
<Action
|
||||||
|
to={`${base}/inventory?tab=products`}
|
||||||
|
icon={<Eye size={15} />}
|
||||||
|
label="View store"
|
||||||
|
note="what a shopper sees"
|
||||||
|
/>
|
||||||
|
<Action
|
||||||
|
to={`${base}/inventory?tab=catalogue`}
|
||||||
|
icon={<Boxes size={15} />}
|
||||||
|
label="Manage catalogue"
|
||||||
|
note="add and price products"
|
||||||
|
/>
|
||||||
|
<Action
|
||||||
|
to={`${base}/inventory?tab=stock`}
|
||||||
|
icon={<RefreshCw size={15} />}
|
||||||
|
label="Update inventory"
|
||||||
|
note="upload today's counts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Action({
|
||||||
|
to, icon, label, note,
|
||||||
|
}: { to: string; icon: ReactNode; label: string; note: string }) {
|
||||||
|
return (
|
||||||
|
<Link to={to} className="quick">
|
||||||
|
<span className="tile" data-tone="brand" aria-hidden>{icon}</span>
|
||||||
|
<span className="quick-text">
|
||||||
|
<span className="quick-label">{label}</span>
|
||||||
|
<span className="quick-note">{note}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={15} className="quick-chev" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user