Compare commits

...

115 Commits

Author SHA1 Message Date
f91044e61a ui 2026-10-08 16:57:16 +05:30
8960fb8ebb delivery slot ui 2026-10-08 15:09:36 +05:30
262b83ffbc delivery slot updated in orders and deliveries fix 2026-10-08 11:06:55 +05:30
d24df891fb delivery slot updated on orders and deliveries 2026-10-06 19:40:42 +05:30
56e8a95c6a delivery slot creation 2026-10-06 17:37:02 +05:30
990dd0dc23 toggle update 2026-10-06 12:20:13 +05:30
a764df225f toggle update 2026-10-06 10:34:48 +05:30
b7f9a6aaeb my stock update page ui 2026-10-05 19:20:08 +05:30
bcb5d1de28 health score toggle ui 2026-10-05 17:05:51 +05:30
7f2110d51e health score toggle in same drawer 2026-10-05 13:05:27 +05:30
1eea29cf23 health score toggle 2026-10-05 12:03:35 +05:30
0a828fbe4f health score: move the non-food early return past the hook
`if (!isEdible(category)) return null` sat on the line above `useQuery`, which
made the hook conditional. React counts hooks per component instance, so one
drawer reused for two products -- a soap and then a biscuit, which is ordinary
browsing in the global catalogue -- went 0 hooks then 1 and threw "rendered more
hooks than during the previous render".

That does not degrade the panel, it unmounts the tree: the health score then
disappears for EVERY product until the page is reloaded, which reads exactly
like the feature having been switched off.

The guard now sits after the hook, and `enabled` carries the intent the early
return was protecting -- a non-food product still asks the service nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 12:23:50 +05:30
12617b5d81 nutrition panel removed 2026-09-30 11:53:35 +05:30
b73f050669 nutrition 2026-09-29 17:30:18 +05:30
e3fc144d05 auto mail generation 2026-09-29 16:48:10 +05:30
f1299fd053 error msg fix 2026-09-28 20:02:18 +05:30
7be012730b fix 2026-09-28 19:50:41 +05:30
9a991fe5ab cod fix 2026-09-28 19:35:16 +05:30
eeef8851e4 domain change 2026-09-28 18:08:55 +05:30
c9616edad9 seperation of nearle admin 2026-09-28 15:43:07 +05:30
153f87b577 nearle admin agent fix 2026-09-28 11:25:32 +05:30
21818320a0 shift fix 2026-09-25 16:19:51 +05:30
9314771aa8 deploy fix 2026-09-25 15:26:23 +05:30
d169d24934 ui fix 2026-09-25 15:21:02 +05:30
72f3bb0961 buddy scroll 2026-09-25 12:31:57 +05:30
46b73c50f4 login fix 2026-09-25 10:37:30 +05:30
174e7dd890 login 2026-09-25 10:14:15 +05:30
3d404665ab rider partner 2026-09-24 19:06:12 +05:30
2f0836df30 status 2026-09-24 16:30:02 +05:30
aca6344cc9 shift 2026-09-24 15:52:27 +05:30
a13f7caddc env changes 2026-09-24 13:18:02 +05:30
23da1872f2 agent changes 2026-09-24 12:37:22 +05:30
beeee893f6 changes 2026-09-24 11:42:02 +05:30
f3fe53d2ac agent 2026-09-23 17:25:09 +05:30
8537b09f33 Wire the Buddy composer to the assistant
The panel has been complete except for the one part that answers. Its
composer was deliberately disabled — it used to accept text, light up the
send button, and swallow the submit, because no assistant endpoint
existed. This connects it to the one that does.

It is enabled only when a model is configured AND the page has an agent,
checked at runtime via /assistant/status rather than assumed at build
time. The placeholder says which of the two is missing:

  "Not connected yet"              no model on this deployment
  "No assistant for this page yet" no agent for this route
  "Ask about this page"            live

Two different facts, two different sentences. A person on Inventory can
move to Sales and get an answer today; a person whose deployment has no
model can do nothing from the browser. Flattening both into "not
connected" would be true and useless.

The old rule is kept: never accept a message nothing will read.

An answer shows its working — the tools it ran, their row counts, and a
link to the page holding the same rows. Buddy states things with the
confidence of a sentence, and the only honest way to present that is
beside the evidence, so a person can disagree with it. Refused calls are
shown too: an answer that quietly dropped one would look like Buddy chose
not to look.

A partial answer says so in amber, not as a grey hint. An answer cut off
mid-sentence reads as a complete one otherwise, which is the failure the
flag exists to prevent.

The thread clears on navigation. An answer about Sales sitting above the
Inventory page reads as being about what is on screen; losing the history
is the smaller cost.

Phase 2 ships one agent, covering Console and Sales. The rest arrive as
backend config, not as changes here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:14:18 +05:30
34c68034de phase 0 and 1 2026-09-23 11:36:10 +05:30
cca3c50a89 rider status 2026-09-22 11:13:15 +05:30
19ed3585ff ui/ux on catalogue 2026-09-19 11:25:41 +05:30
93735e232a ui/ux on catalogue 2026-09-18 19:19:34 +05:30
041dc37861 ui/ux improvement 2026-09-18 16:57:24 +05:30
115a06a02c ui improvement 2026-09-17 17:57:21 +05:30
2cd048e6f0 partner 2026-09-17 11:03:57 +05:30
f25b3fcf65 e2e changes 2026-09-16 17:12:30 +05:30
5cb9872037 type check 2026-09-16 11:43:26 +05:30
98398894a5 design 2026-09-15 20:51:01 +05:30
b2104d21b0 fix the four typecheck errors that broke the build
`npm run build` runs `tsc --noEmit` first, so all four stopped the deploy
before vite ever ran. They came in with the redesign commit.

KpiCard: the note pill was removed from the tile but `note` was still
destructured, and `noUnusedLocals` rejects that. The prop stays declared —
87 call sites across 21 files pass it — and is now documented as accepted
and ignored, the same way `fill` already was. Those 87 strings are written
and never shown; the comment says so rather than leaving it a puzzle.

InventoryPage: dropped an unused SectionHeader import, and `colour` is not
a BadgeProps field. The intent was a brand-coloured "In transit", so that
is now `variant="purple"` — a real variant in BadgeVariantMap, and the
brand is purple.

StoreAccountPage: `current` is a BRANCH, and `gettenantlocations` sends no
tenantname on it (checked on the wire against tenant 1147). So the second
half of `shopQuery.data?.tenantname || current?.tenantname` could never
fire. Removed rather than added to the type — a field the backend does not
send is exactly the bug the previous commit fixed on the summary endpoints.

Verified: tsc clean, 625 tests pass, vite build clean, and the lock file
installs under `npm@10.9.8 ci` — the builder's npm, which the image pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-11 16:50:39 +05:30
0a11f7543c redesign 2026-09-11 16:32:50 +05:30
81b7d32672 deploy fix 2026-09-10 18:05:45 +05:30
8b2c4daef8 updats on the dispatch page 2026-09-10 17:57:00 +05:30
79a8f2b234 map is the resting state of the rider and store views
Opening dispatch showed "Pick one to see its stops" over an empty panel.
That wastes the first look: the whole day IS the answer to "where is my
work", and an operator usually only wants to narrow after seeing it.

Both views now open on the map, drawn from every stop in range. Picking a
rider or a shop filters it; it no longer summons it. The customer view keeps
its placeholder — one customer's drops are all at one address, so there is
no shape to open onto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-10 11:26:09 +05:30
e069068ce7 repair the lock file the leaflet install broke
The deploy died on `npm ci` with "Missing: @emnapi/core@1.11.3 from lock
file". Nothing was wrong with the code — the lock really was incomplete,
and the Dockerfile was right to refuse it.

I broke it. Adding leaflet ran `npm install` under npm 11.6.2; the builder
is node:22-alpine, which ships npm 10.9.8. npm 11 prunes optional platform
packages that npm 10 still validates, and it dropped `@emnapi/core` and
`@emnapi/runtime` — transitive optional deps of
`@tailwindcss/oxide-wasm32-wasi`. Both were present in the lock at b760c1a
and absent from 34bf798 onward.

Regenerated with `npx npm@10.9.8 install --package-lock-only`, which
restores both entries and keeps leaflet 1.9.4 / @types/leaflet 1.9.22.
Verified by running the builder's exact command in a scratch directory:
the old lock reproduces the failure, the new one gives "added 212 packages"
under npm 10.9.8 AND under npm 11.6.2 — so it holds whichever npm the image
ships. No Dockerfile change was needed for that.

The Dockerfile comment did need one. It said the fix was "`npm install`
locally and COMMIT the updated package-lock.json", which is exactly the
step that caused this. It now says to prove the lock against npm 10.9.8 in
a scratch directory before pushing, and how to regenerate it if it fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-09 18:09:11 +05:30
35250717fc map the selected rider and shop, drop the map tab
The separate Map tab is gone. "By rider" and "By store" now draw the map
where the stops table was, so picking a rider from the rail shows their
round on a map and picking a branch shows its day.

A round and a shop's day are both shapes — where the work is, in what order,
how far apart — and a table of addresses shows neither. You can read twenty
rows and still not see that a rider crossed the city twice.

Two views keep the table, on purpose: the waiting queue, whose rows are
ticked to assign them (a checkbox cannot live on a map pin, and nothing
there has been worked yet), and "By customer", where one customer's drops
are all at one address.

The map draws all three places a delivery has, not just the rider:

  shop   a square, one per branch — pickuplat/pickuplon, 500/500 rows
  drop   a circle per stop, by status — droplat/droplon, 500/500 rows
  rider  a hollow ring — riderslat/riderslon, 461/500 and 324/500

Shop and drop are on every row, so the map is never empty on a day with
work in it. The rider fix is real: on delivered rows it sits a median 134 m
from the drop, a reading taken at the customer's door.

The endpoints spell the columns differently — `pickuplon` on getdeliveries
against `pickuplong` on getorders, one letter, and reading the wrong one
puts every shop off the coast of Ghana. `droplat` is filled on deliveries
and empty on orders. `coordOf` takes every spelling so a waiting order maps
the same as a delivered one.

Also fixes the rail itself: it named each group from its first stop's
`ridername`, and that column holds a delivery status on more rows than a
name for two riders in five. The board was listing a phantom rider called
"delivered" carrying 100 of the day's stops, next to the real riders.
Grouping was never wrong — that is on userid — only the label. `isRealName`
now excludes the status vocabulary and both the rail and the map take the
most common name that survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-09 17:47:40 +05:30
34bf7989f7 dispatch map, plan vs actual, and a fleet page
Three of the features the old console had and ours did not, built on what
the data can actually support rather than on what the column names imply.

Two findings changed the shape of the work:

`riderlogs` is not a GPS trail. Every ping a rider sends carries the SAME
coordinate — one rider's 2,404 pings on 14 August all read 11.052998,
76.929958, and the same holds on every day and region checked. Distance,
speed and "time moving" cannot come from it. The Fleet page therefore
reports presence only: who was online and for how long, inferred from the
gaps between check-ins, because `login`, `logout` and `workhours` are empty
on all 320,132 August rows. It says out loud that it cannot tell a rider
parked all day from one who crossed the city.

The delivery ladder is not the order its columns are in. `starttime` is
later than `arrivaltime` on 316 of 316 rows, which looks corrupt and is not:
`starttime` is per-DROP, stamped when the rider sets off for that address
having finished the last one. Read as assign -> arrive -> pickup -> start ->
deliver, every duration is positive. On tenant 916 that shows the bottleneck
is not the riding: a median 69.5 minutes passes between handing an order to
a rider and that rider reaching the shop, against 0.6 minutes at the counter.

- Map tab on dispatch, fed by `deliveries.riderslat/lon` — the only rider
  positions that move (353 distinct across 461 rows). Tenant-scoped, so a
  shop sees its own rounds. The line joins stops in worked order and says
  it is not a route.
- Plan vs actual tab: promised against delivered, and a step breakdown of
  where the hours go. `actualkms` is excluded — it equals the planned `kms`
  to the decimal on every delivered row, so it is a copy, not a measurement.
- Fleet page in the platform console: a presence gantt and a map of where
  each rider is registered.
- `ridername` holds a delivery status on more rows than it holds a name for
  two riders in five, so names are resolved by excluding the status
  vocabulary first.
- leaflet, wrapped directly rather than via react-leaflet, lazy-loaded so
  only the pages with a map pay for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
2026-09-09 17:02:21 +05:30
b760c1a078 rider partner page 2026-09-09 15:43:23 +05:30
32c612a10d qr code 2026-09-09 11:19:05 +05:30
a94c23c20e profile name change 2026-09-08 19:20:42 +05:30
035ceb44a7 profile page 2026-09-08 19:01:40 +05:30
33c4542ddf guide 2026-09-08 17:03:17 +05:30
59828811f9 timing 2026-09-08 15:40:48 +05:30
024fc4adfd bulk release 2026-09-08 12:59:17 +05:30
12269deeda category id 2026-09-08 12:17:56 +05:30
d54fadef20 category changes 2026-09-08 12:03:23 +05:30
1a37949bbe category according to the agent 2026-09-08 10:36:14 +05:30
fdecfe6cc1 console 2026-09-07 18:05:54 +05:30
56f183f6f6 console category 2026-09-07 17:50:57 +05:30
954dfb8c00 category 2026-09-07 17:27:27 +05:30
c31696ce41 enhancement 2026-09-07 16:46:47 +05:30
05ac76f5f0 drawer and upload 2026-09-07 13:02:35 +05:30
8defbd418f pagenation 2026-09-07 11:00:44 +05:30
d3785cc303 dispatch fix 2026-09-05 15:38:40 +05:30
5006a4f0d4 dispatch page 2026-09-05 15:01:16 +05:30
37b0833328 health score 2026-09-04 17:31:57 +05:30
9f1593ff87 health score in drawer 2026-09-04 17:20:24 +05:30
b8150eb158 health score 2026-09-04 16:36:50 +05:30
e1a16377ea rider creation 2026-09-04 16:03:46 +05:30
416c50755b assign option 2026-09-04 11:31:07 +05:30
743aa93e20 deliveries 2026-09-03 19:13:04 +05:30
25073659d8 terminal redesign 2026-09-03 16:47:04 +05:30
a8fc4bee69 cards 2026-09-03 16:22:48 +05:30
bbe313ba48 cards removed 2026-09-03 15:45:15 +05:30
5345809951 price fix for app 2026-09-03 15:08:22 +05:30
350eb8f29c confirm received 2026-09-03 14:42:56 +05:30
e1bb0a5307 redesign console page 2026-09-03 12:31:07 +05:30
d39956a2f1 ask again 2026-09-03 11:17:31 +05:30
aad72cfe42 bulk request 2026-09-02 16:49:47 +05:30
d22b79e109 tenant profile 2026-09-02 12:29:17 +05:30
52be0e30ac login changes 2026-09-02 11:46:41 +05:30
7df8a49e5f setup onboarding page 2026-09-02 11:26:00 +05:30
b72bbd2f12 changes 2026-09-02 10:48:47 +05:30
98712493c4 role id solved 2026-09-01 13:43:51 +05:30
599508bc20 login changes 2026-09-01 13:22:02 +05:30
9b24d9ec09 changes 2026-09-01 13:05:37 +05:30
22c9f45b44 skip button 2026-09-01 12:54:40 +05:30
fda463f123 guidence fix 2026-09-01 12:32:19 +05:30
a8e0dc069f guidence 2026-09-01 12:00:46 +05:30
fe78c54eeb filters 2026-08-31 16:40:07 +05:30
5304d9aeb6 updated on shelfon store catalogue 2026-08-31 16:26:58 +05:30
55ed13c284 fix on shelf 2026-08-31 15:50:59 +05:30
b4e77fd6ea updated on shelf 2026-08-31 14:58:55 +05:30
bd133e7cc6 agent conformation 2026-08-31 12:32:09 +05:30
7e3571f90e changes 2026-08-29 16:45:04 +05:30
f4a2962981 product name alone made required 2026-08-29 12:46:14 +05:30
29613e25ee upload sheet according to the tenant 2026-08-29 12:08:38 +05:30
32c3bef3ad test bugs fixed 2026-08-28 18:18:50 +05:30
670d193f36 changes with app products 2026-08-28 12:22:03 +05:30
590e31e6a9 changes with catalogue 2026-08-28 11:57:41 +05:30
dd6a4771ed page loading 2026-08-28 11:15:47 +05:30
9f64b42f8a removed files 2026-08-28 11:03:56 +05:30
bf4dc4e888 changes 2026-08-27 17:48:20 +05:30
0a3e6302b9 changes 2026-08-27 17:40:57 +05:30
b7233acd8a changes 2026-08-27 17:39:47 +05:30
7a8c8dc2d7 build: dockerignore, drop _to_delete from the repo 2026-08-27 17:34:58 +05:30
9a352e9f71 docker issue 2026-08-27 17:12:54 +05:30
904a5d816f env edited 2026-08-27 17:08:21 +05:30
eab30640c0 nginx 2026-08-27 15:37:30 +05:30
285 changed files with 52204 additions and 6354 deletions

31
.dockerignore Normal file
View 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
View 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
View File

@@ -4,3 +4,12 @@ dist
*.local
.DS_Store
.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/

View File

@@ -4,9 +4,83 @@ FROM node:22-alpine AS builder
WORKDIR /app
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 . .
# 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
# 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`
# would survive that today, but only by luck, and a config silently rewritten at
# 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
# 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.
# Empty by default — and this line is LOAD-BEARING. Do not delete 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=""
# 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/
EXPOSE 80
EXPOSE 80 3000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -35,17 +35,36 @@ deployed build set `VITE_API_BASE` (see `.env.example`).
## What's built
Only the **Nearle Admin** workspace so far:
All three workspaces. Which one opens is decided by the account, not chosen —
see `src/auth/roles.ts`.
**Nearle Admin** (`issuperadmin`) — the platform operator:
- `/nearle/stores` — every tenant, branch counts, per-tenant performance
- `/nearle/stores/:tenantId` — one tenant's branches, orders and revenue
- `/nearle/onboard/tenant` — provision a merchant group
- `/nearle/onboard/branch` — commission an outlet with its delivery thresholds
- `/nearle/onboard/tenant` — provision a merchant group and its first outlet
- `/nearle/catalogue` — the global catalogue, plus both product-import paths
- `/nearle/partners` — rider partners, and the riders under each
- `/nearle/dispatch` — every partner's live work and shifts
- `/nearle/uploads` — spreadsheets sent to the catalogue service
`/admin/*` (Store Admin) and `/store/*` (Store Manager) render a named
placeholder rather than a 404, so those roles land somewhere that explains
itself.
**Store Admin** (roleid 1 and 3) — one merchant, every branch:
- `/admin/console` — online and counter sales side by side, per branch
- `/admin/sales` · `/admin/dispatch` · `/admin/inventory` · `/admin/reports`
- `/admin/branches/new` — commission an outlet with its delivery thresholds
- `/admin/users` — back-office people and till accounts
- `/admin/terminals` · `/admin/uploads` · `/admin/profile` · `/admin/onboarding`
**Store user** (everything else) — one branch, scoped to it:
- `/store/console` · `/store/products` · `/store/sales` · `/store/dispatch`
- `/store/reports` · `/store/customers` · `/store/terminals` · `/store/staff`
- `/store/uploads` · `/store/account` · `/store/setup`
Two routes are redirects rather than pages, and deliberately:
`/nearle/onboard/branch` → `/nearle/stores` (a branch is commissioned from the
tenant that will own it), and `/nearle/fleet` → `/nearle/dispatch`.
## Things about the backend that shape this code

View File

View File

View File

View File

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

View File

@@ -22,6 +22,7 @@
server {
listen 80;
listen 3000;
server_name _;
# 10 MB is the ingest service's own file limit, so anything larger is going
@@ -36,14 +37,40 @@ server {
index index.html;
# React Router owns the paths, so an unknown one is a route, not a 404.
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,
# or a deploy leaves people on the previous bundle until they force-reload.
# Hashed filenames, so these can be cached hard — the hash changes when the
# content does, which is what makes a year safe.
location /assets/ {
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 ───────────────────────────────────────────────────────────────
@@ -78,10 +105,64 @@ server {
# 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.
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_set_header Host mcp.nearle.ai.in;
proxy_set_header X-API-Key "${INGEST_TOKEN}";
# Derived from the upstream rather than hardcoded, so it stays correct
# 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_http_version 1.1;

1173
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,22 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3100",
"typecheck": "tsc --noEmit",
"test": "tsx --test \"src/**/*.test.ts\"",
"test": "tsx --import ./tools/stub-css.mjs --test \"src/**/*.test.ts\" \"src/**/*.test.tsx\"",
"contract": "node scripts/contract.mjs",
"db": "node scripts/db.mjs",
"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": {
"@astryxdesign/core": "^0.4.5",
"@stylexjs/stylex": "^0.19.0",
"@tanstack/react-query": "^5.101.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -27,10 +34,13 @@
"devDependencies": {
"@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3",
"@types/jsdom": "^30.0.0",
"@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.20.3",
"typescript": "^7.0.2",

View File

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

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

View File

@@ -32,7 +32,53 @@ import { fileURLToPath } from 'node: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. */
const ENV_CANDIDATES = [
@@ -223,6 +269,103 @@ async function sql(statement) {
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 ─────────────────────────────────────────────────────────────── */
const [command, ...rest] = process.argv.slice(2);
@@ -232,6 +375,7 @@ const COMMANDS = {
user: () => user(rest[0]),
setpw: () => setpw(rest[0], rest[1]),
sql: () => sql(rest.join(' ')),
appgap: () => appgap(rest[0], rest[1]),
};
if (!command || !COMMANDS[command]) {
@@ -243,9 +387,13 @@ if (!command || !COMMANDS[command]) {
' user <email> show an account (never prints the password)',
' setpw <email> <password> set a password on an account that has none',
' sql "<select ...>" read-only SQL',
' appgap <tenant> <outlet> why the app shows fewer products than the console',
].join('\n'),
);
process.exit(command ? 1 : 0);
}
// Locate Hasura before anything talks to it.
await resolveEndpoint();
await COMMANDS[command]();

167
scripts/mapPreview.mjs Normal file
View 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
View 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();

View File

@@ -3,8 +3,9 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { Spinner } from '@astryxdesign/core/Spinner';
import { RequireRole, useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { withStaleChunkRecovery } from '@/lib/staleChunk';
import { LoginPage } from '@/features/auth/LoginPage';
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
import { SetPasswordPage } from '@/features/auth/SetPasswordPage';
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
@@ -15,21 +16,37 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell';
* means every visitor downloads the spreadsheet importer to look at a dashboard.
* 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>>) =>
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'));
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
its stylesheet, which nobody who never opens the fleet map should download. */
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 DispatchPage = named('DispatchPage', () => import('@/features/store-admin/pages/DispatchPage'));
const InventoryPage = named('InventoryPage', () => import('@/features/store-admin/pages/InventoryPage'));
const ReportsPage = named('ReportsPage', () => import('@/features/store-admin/pages/ReportsPage'));
const OnboardBranchPage = named('OnboardBranchPage', () => import('@/features/store-admin/pages/OnboardBranchPage'));
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
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 StoreStaffPage = named('StoreStaffPage', () => import('@/features/store-user/pages/StoreStaffPage'));
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() {
return (
@@ -61,34 +80,25 @@ export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Reached only from an invitation email, and public by necessity — the
account has no password yet, so there is no way to obtain a session
first. The token in the query string is the whole credential. */}
<Route path="/set-password" element={<SetPasswordPage />} />
{/* Nearle Admin — the platform workspace */}
<Route
path="/nearle"
element={
<RequireRole role="nearle-admin">
<Suspense fallback={<RouteFallback />}>
<NearleAdminShell />
</Suspense>
</RequireRole>
}
>
<Route index element={<Navigate to="/nearle/stores" replace />} />
<Route path="stores" element={<StoresPage />} />
<Route path="stores/:tenantId" element={<StoreDetailPage />} />
<Route path="onboard/tenant" element={<OnboardTenantPage />} />
{/* Branch onboarding moved to the Store Admin workspace — the merchant
opens their own outlets. Kept as a redirect rather than deleted so a
bookmark or a stale link lands on the directory instead of a 404. */}
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
<Route path="catalogue" element={<GlobalCataloguePage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
never bounce out to a HOME_ROUTE that points back into this
workspace and loop. */}
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
</Route>
{/* Nearle staff have their own application.
The platform workspace lived here until the two consoles were split.
It is now `nearle-platform`, a separate repository deployed on its own
domain, and this one serves merchants and their branch users only.
`/nearle/*` is therefore not a protected path here — it is not a path
at all, and falls to the catch-all like any other unknown URL. A
`nearle-admin` account cannot sign in either: `login` and `restore`
refuse it before a session is written. */}
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
<Route
path="/admin"
element={
@@ -102,6 +112,7 @@ export function App() {
<Route index element={<Navigate to="/admin/console" replace />} />
<Route path="console" element={<ConsolePage />} />
<Route path="sales" element={<SalesPage />} />
<Route path="dispatch" element={<DispatchPage />} />
<Route path="inventory" element={<InventoryPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="branches/new" element={<OnboardBranchPage />} />
@@ -109,6 +120,9 @@ export function App() {
anyone works from day to day. See `AppShellProps.manageItems`. */}
<Route path="users" element={<UsersPage />} />
<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.
Without this, an unknown sub-path escapes to the global `*`, which
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="products" element={<StoreProductsPage />} />
<Route path="sales" element={<SalesPage />} />
<Route path="dispatch" element={<DispatchPage />} />
<Route path="reports" element={<ReportsPage />} />
{/* Reached from the account menu. A shop does not commission outlets,
so there is deliberately no `branches/new` here. */}
<Route path="customers" element={<StoreCustomersPage />} />
<Route path="terminals" element={<TerminalsPage />} />
<Route path="staff" element={<StoreStaffPage />} />
<Route path="uploads" element={<StoreUploadsPage />} />
<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>
{/* `user` is only ever a merchant role — `login` and `restore` refuse a
Nearle staff account before a session is written — so its home route
is always one of the two workspaces above, and this cannot bounce
somewhere that does not exist. */}
<Route
path="*"
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}

117
src/api/assistant.ts Normal file
View 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 });
}

View File

@@ -36,6 +36,30 @@ export const catalogueApi = {
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
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. */
categories: (brand: string) =>
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
@@ -50,6 +74,24 @@ export const catalogueApi = {
product: (brand: string, sku: string) =>
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
* 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 }),
};
/** 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 {
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}`;
}
/**
* 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;
}

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

View File

@@ -6,14 +6,83 @@
* changes. Nothing else in the app calls `fetch`.
*/
import { authHeader, forgetSession, readSessionToken } from '@/auth/token';
import type { FiestaEnvelope } from './types';
/**
* In dev, Vite proxies `/fiesta` -> https://fiesta.nearle.app (see
* vite.config.ts), which keeps the network tab honest and sidesteps preflight
* surprises. In production the deployed host is set by VITE_API_BASE.
* A 401 on a call we authenticated means the session is over.
*
* Twelve hours after signing in, or the moment the signing key is rotated under
* an open tab, every request starts coming back 401. Without this the console
* keeps sending the dead token and each page renders its own error — which a
* shopkeeper reads as "my data has gone", not as "sign in again". The screen
* fills with failures and nothing tells them the one thing that would fix it.
*
* Only when a token was actually SENT. A 401 on an anonymous call is the
* server declining to serve a stranger, not a session ending — and the
* sign-in probe deliberately posts with no password to read a 401 back, so
* reacting to that one would clear the session at the login screen and make
* signing in impossible.
*
* `location.reload()` rather than a router push: the session is held in React
* state that this module cannot reach, and a reload is the one move guaranteed
* to land on the sign-in screen from anywhere in the app. It happens once,
* because the storage is cleared first — the reloaded app has no token, so the
* next 401 cannot loop.
*/
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. */
export const WEB = '/live/api/v1/web';
@@ -94,7 +163,17 @@ interface RequestOptions {
signal?: AbortSignal;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
/**
* One request, answered with the WHOLE envelope.
*
* `request` below is this plus "take the payload out", which is what nearly
* every caller wants. A handful need a field that sits BESIDE the payload —
* `invited` on onboarding is the one this was extracted for — and the only way
* to read one used to be `requestEnvelope`, which does none of the checking: no
* 401 handling, no `status: false`, no thrown `FiestaError`. So a caller that
* wanted one extra field had to give up all the error handling to get it.
*/
async function send<T>(path: string, options: RequestOptions = {}): Promise<FiestaEnvelope<T>> {
const { method = 'GET', params, body, signal } = options;
// There is exactly one path out of this function and it goes to `fetch`.
@@ -108,7 +187,10 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
const init: RequestInit = {
method,
headers: { Accept: 'application/json' },
// `authHeader()` is read per request, never captured: sign-in and sign-out
// both happen while the app is running, and a header bound once would keep
// authorising calls for whoever signed in first.
headers: { Accept: 'application/json', ...authHeader() },
signal: signal ?? null,
};
@@ -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);
}
if (response.status === 401) {
endDeadSession(path);
}
if (!response.ok || envelope.status === false) {
throw new FiestaError(
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
// `data` instead — `products/getallproducts` and `products/create` among the
// ones the console calls (`productController.go:400,206`). Reading only
// `details` handed those two callers `undefined` with no error anywhere.
return envelope;
}
/**
* The payload, unwrapped.
*
* Most handlers put it in `details`, but a handful answer with `data` instead —
* `products/getallproducts` and `products/create` among the ones the console
* calls (`productController.go:400,206`). Reading only `details` handed those
* two callers `undefined` with no error anywhere.
*/
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const envelope = await send<T>(path, options);
return (envelope.details ?? envelope.data) as T;
}
@@ -165,7 +260,7 @@ async function requestEnvelope<T>(
const { method = 'GET', params, body } = options;
const url = `${API_BASE}${path}${toQueryString(params)}`;
const init: RequestInit = { method, headers: { Accept: 'application/json' } };
const init: RequestInit = { method, headers: { Accept: 'application/json', ...authHeader() } };
if (body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
@@ -212,6 +307,16 @@ export const api = {
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'POST', body, params }),
/**
* A POST whose answer carries something beside the payload.
*
* Same checking as `post` — a failure still throws a `FiestaError` — so a
* caller reading one extra envelope field does not give up the error handling
* to get at it. `tenants/createtenantuser` needs `invited`.
*/
postEnvelope: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
send<T>(path, { method: 'POST', body, params }),
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'PUT', body, params }),

View File

@@ -29,6 +29,24 @@ export interface CustomerInfo {
deliverylocationid?: number;
tenantlocationid?: number;
applocationid?: number;
/**
* Where the customer is, as text — the column type in `app_customers`.
*
* Sent by `gettenantcustomers` on 97% of rows (measured across 31 customers
* at 7 shops, 2026-09-15) and simply absent from this interface until now, so
* the one nearly-complete piece of geography the backend has about a shop's
* customers was invisible to every page.
*/
latitude?: string;
longitude?: string;
/**
* Empty on every customer row on the platform — 0 of 31.
*
* Kept declared because the column exists and a future write could fill it,
* but nothing should render an Active/Inactive state from it: a badge that
* reads the same on every row is decoration, and one that reads blank is
* worse.
*/
status?: string;
}

490
src/api/deliveries.ts Normal file
View 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
View 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
View 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

View File

@@ -12,6 +12,7 @@ import type {
DeliveryRow,
DeliverySummary,
LocationOrderSummary,
OrderItem,
OrderRow,
OrderSummary,
PosLocationHealth,
@@ -29,6 +30,18 @@ export interface OrderQuery extends DateRange {
tenantid: number;
/** Omit for every branch of the tenant. */
locationid?: number;
/**
* One delivery partner's work, ACROSS every merchant they serve.
*
* The platform's own view of dispatch: a partner's riders carry for many
* shops at once — partner 60 answered with 376 deliveries spanning 12
* merchants — and no tenant-scoped read can show that. Verified live on
* 2026-09-11.
*
* Never sent alongside a tenantid. The endpoint treats the two as separate
* doors onto the same table, not as filters that combine.
*/
partnerid?: number;
status?: string;
keyword?: string;
pageno?: number;
@@ -51,7 +64,7 @@ export const insightsApi = {
*/
orders: (query: OrderQuery) =>
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
tenantid: query.tenantid,
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
@@ -61,6 +74,41 @@ export const insightsApi = {
pagesize: query.pagesize ?? 50,
}),
/**
* Every order in a window, not the first page of them.
*
* Reports totals its figures from order ROWS — `getlocationsummary` carries no
* money and ignores the date picker, so the rows are the only source that both
* has revenue and respects the range. Reducing over a single `pagesize: 500`
* read made every one of those figures a silent lie the moment a tenant traded
* more than five hundred orders in the window: the page showed a total, gave no
* sign it was a partial one, and `api.list` discards the envelope so nothing
* downstream could even detect the cut.
*
* Paging stops on a SHORT PAGE rather than on a count the list endpoint does
* not return — the same rule `catalogue.idsByImageId` follows, and for the same
* reason: a total we would have to trust is worse than a page we can measure.
*
* `maxPages` is a real bound, not a formality. Something has to stop a loop
* pointed at production, and a window wide enough to exceed it is a window the
* reader should be told about rather than one we quietly keep fetching. Hence
* `truncated`, which the caller is expected to surface — the whole point of
* this function is that a partial total never again passes for a complete one.
*/
ordersAll: async (
query: OrderQuery,
{ pagesize = 500, maxPages = 10 }: { pagesize?: number; maxPages?: number } = {},
): Promise<{ rows: OrderRow[]; truncated: boolean }> => {
const rows: OrderRow[] = [];
/* `pageno` is 1-based on this endpoint — the controller floors <= 0 to 1. */
for (let page = 1; page <= maxPages; page += 1) {
const batch = await insightsApi.orders({ ...query, pageno: page, pagesize });
rows.push(...batch);
if (batch.length < pagesize) return { rows, truncated: false };
}
return { rows, truncated: true };
},
/**
* The delivery jobs.
*
@@ -75,7 +123,7 @@ export const insightsApi = {
*/
deliveries: (query: OrderQuery) =>
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
tenantid: query.tenantid,
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
@@ -95,8 +143,52 @@ export const insightsApi = {
revenueSummary: (tenantid: number, range: DateRange = {}) =>
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 = {}) =>
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),

166
src/api/nutrition.ts Normal file
View 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
View 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,
),
};

View File

@@ -21,7 +21,7 @@
* 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';
/* ── Back-office staff ───────────────────────────────────────────────────── */
@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
status?: string;
}
/**
* What happened to somebody's first-password invitation.
*
* Reported beside the person rather than folded into success or failure: they are
* hired either way, and an unsent invitation is a task — resend, or correct the
* address — not a hire to retry.
*/
export interface InviteOutcome {
sent: boolean;
/** Why not — an unconfigured mail host, a rejecting relay. Absent when it sent. */
reason?: string;
}
export interface CreateStaffResult {
person: StaffInfo;
invite: InviteOutcome;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
@@ -57,17 +75,70 @@ export const staffApi = {
* back-office roles and most accounts carry an id absent from it, so any
* mapping written client-side is wrong."
*
* On the MOB prefix, and that is not a choice: `getstaffs` is registered on
* `/v1/mob/tenants` only (`tenantroutes.go:35`) and has no `/web` twin, so the
* path this used to call did not exist. The old console avoided the question
* by using `users/getallusers`, whose SQL selects no `rolename` at all — it
* had to map role ids client-side, which is the thing the backend warns
* against above.
* On WEB now. It used to be on MOB because `getstaffs` was registered under
* `/v1/mob/tenants` alone and had no `/web` twin — back-office staff were
* reachable only through the customer app's door, which is a large part of
* why this console never had a people screen. The twin now exists; the MOB
* registration is left in place in case something else calls it.
*
* 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) =>
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.
@@ -122,11 +193,23 @@ export const posUsersApi = {
* non-array and hands back `[]`, so the page showed "no till accounts" for a
* shop that had them. Same shape trap as `/health/location`.
*/
list: (tenantid: number, locationid: number) =>
list: (tenantid: number, locationid: number, includeInactive = false) =>
api
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
tenantid,
locationid,
/*
Off by default, because that is what every existing caller assumed.
The listing excludes inactive accounts unless asked
(`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
query string. Nothing sent it, which had two consequences: a supervisor
who switched a cashier off in the drawer watched them disappear from the
list with no trace and no way back, and any Status column could only
ever render "Active" because that was the only status a row could have.
*/
...(includeInactive ? { include_inactive: 'true' } : {}),
})
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
@@ -155,13 +238,46 @@ export const posUsersApi = {
*
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
*/
shifts: (tenantid: number, locationid: number) =>
/**
* The tenant's shift windows.
*
* `locationid` is optional and usually omitted. A shift belongs to the
* business, so the tenant's set is what every picker in the console should
* offer; naming a branch narrows to the tenant's plus that branch's own, for
* the outlet that genuinely runs different hours.
*/
shifts: (tenantid: number, locationid?: number) =>
api
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
tenantid,
locationid,
...(locationid ? { locationid } : {}),
})
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
/**
* Open a shift window at one branch.
*
* The endpoint has existed since till staff were built; nothing in the console
* called it. So `getstaffshifts` answered `{"shifts": []}` at every branch —
* the comment on StoreStaffPage says exactly that — and the picker on this
* drawer offered "Any shift" and nothing else, for everyone, permanently.
*
* `weekdays` is a seven-character mask starting Monday; empty means every day.
* The server rejects anything that is not seven 0/1 characters, so it is sent
* as the mask rather than as a list the console would have to encode twice.
*/
createShift: (
tenantid: number,
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
/** Only for a shop that genuinely runs different hours from the business. */
locationid?: number,
) =>
api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, {
tenantid,
// Zero means the whole tenant, which is the ordinary case.
locationid: locationid ?? 0,
...shift,
}),
};
/**

View File

@@ -20,7 +20,7 @@
* know about it.
*/
import { api, WEB } from './client';
import { api, MOB, WEB } from './client';
import type {
ImportCatalogueProductRequest,
Product,
@@ -29,6 +29,8 @@ import type {
ProductStockRequest,
ProductSubCategory,
} from './types';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
export interface LocationProductQuery {
tenantid: number;
@@ -38,13 +40,25 @@ export interface LocationProductQuery {
}
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) =>
api.list<Product>(`${WEB}/products/getlocationproducts`, {
tenantid: query.tenantid,
locationid: query.locationid,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 50,
pageno: (query.pageno ?? 0) + 1,
pagesize: query.pagesize ?? 200,
}),
/**
@@ -57,6 +71,11 @@ export const productsApi = {
* SKU lookup in `importSheetProducts` then read as a product with no
* `productid`: every sheet import resolved zero ids and wrote no locations
* and no stock. Flattened here so no caller sees the grouping.
*
* Nothing calls this today — the importer that did now gets its ids from the
* create response. Kept because it is the only wrapper for a real endpoint
* and the grouping above is the sort of thing the next caller would be
* caught by all over again.
*/
allProducts: (tenantid: number) =>
api
@@ -79,7 +98,35 @@ export const productsApi = {
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
/** Single product only — the backend parses one object, not an array. */
/**
* Turn one product's health score on or off for this shop.
*
* The merchant's call, not Nearle's. The score comes from a third party
* matching a reference product by name — often under 60% confidence — and a
* shopkeeper holding the packet is better placed than that matcher to say
* whether the rating describes what they sell.
*
* Hides the SCORE only. The nutrition table is unaffected.
*/
setShowHealthScore: (tenantid: number, productid: number, showhealthscore: boolean) =>
api.put<unknown>(`${WEB}/products/showhealthscore`, {
tenantid,
productid,
showhealthscore,
}),
/**
* Single product only — the backend parses one object, not an array.
*
* Answers with the created row, `productid` included. It used to echo back
* the request body, which meant `productid: 0` every time: the id is
* assigned by the database and nothing read it back. Callers that needed it
* — and pricing and stocking a product both do — had to re-read the
* catalogue and find their own row again by SKU.
*
* The payload arrives under `data` rather than `details`, which the client
* already handles.
*/
createProduct: (product: Partial<Product>) =>
api.post<Product>(`${WEB}/products/create`, product),
@@ -129,6 +176,33 @@ export const productsApi = {
{ 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. */
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
@@ -142,6 +216,13 @@ export const productsApi = {
export interface SheetProductRow {
productname: 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;
subcategoryid: number;
retailprice: number;
@@ -197,14 +278,41 @@ export async function importSheetProducts(
const failures: SheetImportResult['failures'] = [];
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()) {
try {
await productsApi.createProduct({
const created = await productsApi.createProduct({
tenantid,
productname: row.productname,
productsku: row.productsku,
categoryid: row.categoryid,
subcategoryid: row.subcategoryid,
// ALWAYS 2 — the app filters on it and would drop anything else. The
// aisle a shopper reads is the subcategory.
categoryid: APP_BROWSE_CATEGORY,
subcategoryid: subcategoryIdFor(row),
retailprice: row.retailprice,
productcost: row.productcost,
taxpercent: row.taxpercent,
@@ -214,51 +322,61 @@ export async function importSheetProducts(
productdesc: row.productdesc,
productstatus: 'Active',
});
/*
The id comes back from the create now.
This loop used to collect SKUs, then read the tenant's ENTIRE catalogue
back, build a SKU→product map and match its own rows against it, because
`POST /products/create` answered `productid: 0`. That is fixed on the
backend — the id is the database's and it is returned — so the second
read and the matching are both gone.
Worth saying what the old way actually cost, because it was not only the
extra request. Matching on SKU means matching on a column nothing
enforces: this importer creates duplicates on re-upload by design, and
the map kept the LAST row for a SKU, so a second upload sent the new
product's price and stock to whichever copy happened to win. A row whose
SKU was blank, or trimmed differently by the sheet, could not be found at
all and was reported as "Created, but could not be found again by SKU" —
a message about the console's own bookkeeping that a shop could do
nothing with.
A zero here would be worse than the old behaviour, so it is checked
rather than assumed: the product exists either way, and saying so is more
use than silently pricing product 0.
*/
if (!created?.productid) {
failures.push({
row,
reason: 'Created, but the server did not return its id — price and stock were not set',
});
onProgress?.(index + 1, rows.length);
continue;
}
createdSkus.push(row.productsku);
locationRows.push({
tenantid,
locationid,
productid: created.productid,
price: row.retailprice,
status: 'available',
});
stockRows.push({
tenantid,
locationid,
productid: created.productid,
quantity: row.quantity,
stocktype: 'in',
status: 'Active',
});
} catch (error) {
failures.push({ row, reason: error instanceof Error ? error.message : 'Create failed' });
}
onProgress?.(index + 1, rows.length);
}
if (createdSkus.length === 0) {
return { created: 0, linked: 0, stocked: 0, failures };
}
// Resolve the ids the create endpoint refused to hand back.
const all = await productsApi.allProducts(tenantid);
const bySku = new Map<string, Product>();
for (const product of all) {
if (product.productsku) bySku.set(product.productsku, product);
}
const locationRows: ProductLocationRequest[] = [];
const stockRows: ProductStockRequest[] = [];
for (const row of rows) {
if (!createdSkus.includes(row.productsku)) continue;
const product = bySku.get(row.productsku);
if (!product) {
failures.push({ row, reason: 'Created, but could not be found again by SKU' });
continue;
}
locationRows.push({
tenantid,
locationid,
productid: product.productid,
price: row.retailprice,
status: 'available',
});
stockRows.push({
tenantid,
locationid,
productid: product.productid,
quantity: row.quantity,
stocktype: 'in',
status: 'Active',
});
}
if (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
@@ -269,3 +387,66 @@ export async function importSheetProducts(
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
View 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,
};

View File

@@ -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
* 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 = {
pending: 'Pending',
approved: 'Approved',
received: 'Received',
rejected: 'Rejected',
} as const;
@@ -40,6 +52,18 @@ export interface StockRequestQuery {
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 {
tenantid: 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
* different amount: the service reads the quantity off the request row, not
* off this call.
* The shelf is written when the branch confirms the goods ARRIVED, not when
* the admin agrees to send them — see `confirmArrival`.
*/
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`, {
requestid,
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: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {

112
src/api/telemetry.ts Normal file
View 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;
},
};

View File

@@ -1,6 +1,8 @@
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { InviteOutcome } from './people';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
@@ -9,6 +11,21 @@ export interface CreateTenantRequest {
companyname: string;
primarycontact: 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;
categoryid: number;
subcategoryid?: number;
@@ -28,6 +45,31 @@ export interface CreateTenantRequest {
/** Everything the branch-onboarding form collects. */
export interface CreateBranchRequest {
tenantid: number;
/**
* The delivery region, inherited from the tenant's existing outlets.
*
* `tenantlocations.applocationid` has no column default, so a create that
* omits it stores 0 — and `resolveOfflineLocationContext` in
* `orderRepository.go` calls this column "authoritative", with no fallback
* anywhere for a zero. It is also copied straight onto the login the backend
* spawns for the branch, so the outlet AND the person running it both end up
* in no region at all.
*
* Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
* 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
*/
applocationid?: number;
/**
* Also inherited, and also without a column default.
*
* `orderRepository.go` documents the consequence in its own comment —
* "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
* orders nonetheless use non-zero values" — and works around it by copying
* the scaffolding off the most recent real order at that outlet. A branch
* commissioned five minutes ago has no such order, so the workaround has
* nothing to copy and the joins are left to resolve against a zero.
*/
moduleid?: number;
locationname: string;
email?: string;
contactno?: string;
@@ -43,6 +85,35 @@ export interface CreateBranchRequest {
deliveryradius?: number;
deliverymins?: number;
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 {
@@ -110,18 +181,92 @@ export const tenantsApi = {
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
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*
* `invite` reports the spawned login's first-password email. It is `sent:
* false` with NO reason when an `operatorid` was named — nothing was created,
* so there was nothing to send, and that is not a failure to report.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
api
.postEnvelope<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body)
.then((envelope) => ({
branch: (envelope.details ?? {}) as TenantLocation,
invite: {
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
// The spawned login, so a failed invitation can be resent without
// hunting for the row by eye. 0 when an existing person was placed.
operatorUserid: envelope.inviteuserid ?? 0,
})),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
/**
* 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),
};
/**
@@ -139,6 +284,9 @@ function toTenantBody(form: CreateTenantRequest): Record<string, unknown> {
companyname: form.companyname,
primaryemail: form.primaryemail,
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,
subcategoryid: form.subcategoryid ?? 0,
address: form.address,
@@ -188,4 +336,14 @@ export const utilsApi = {
* invisible to onboarding until someone edits the frontend.
*/
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 }),
};

View File

@@ -33,6 +33,83 @@ export interface FiestaEnvelope<T> {
data?: T;
/** Present on the tenant login endpoints. */
tenantform?: boolean;
/**
* The console session, issued by the login endpoints only.
*
* Sits beside `details` rather than inside it because it is not a fact about
* the user — it is what proves the request is theirs. Every later call sends
* it as `Authorization: Bearer`, and `middleware.WebAuth` reads the tenant out
* of it instead of believing the one on the query string.
*/
token?: string;
/** Unix seconds. The tab closing normally ends the session well before this. */
tokenexpiresat?: number;
/**
* Order totals, from `orders/getorderdetails` only.
*
* Beside `details` rather than inside it because the line items are a list
* and this is one figure about the order as a whole. `OrderDetail.Orderamount`
* is tagged `json:"-"` on the server, so the authoritative total exists HERE
* and nowhere else in the response — summing the lines is an approximation of
* a number Fiesta has already worked out.
*/
pricedetails?: { orderamount?: number; totaltaxamount?: number };
/**
* Whether a newly created account was emailed its first-password link.
* On the three endpoints that create one: `users/create`,
* `tenants/createstaff` and `tenants/createtenantlocation`.
*
* Beside `details` rather than inside it because it is not a fact about the
* person or the branch — those exist either way. It is what happened to a
* separate side effect, and the only one an operator can act on.
*/
invited?: boolean;
/** Why it did not send. Absent when it did. */
invitereason?: string;
/**
* Who to resend to, from `tenants/createtenantlocation` only.
*
* `details` there is the branch row, and the login the branch spawned lives in
* `app_users` — so this is the only way to name it. 0 when an existing person
* was placed and no account was created.
*/
inviteuserid?: number;
}
/* ────────────────────────────────────────────────────────────────────────────
Order line items — models/order.go:OrderDetail
──────────────────────────────────────────────────────────────────────────── */
/**
* One product on an order.
*
* Returned by `orders/getorderdetails`, which is the ONLY read that carries
* them — the list endpoints give an order's totals and never what is in it.
* That is why every screen showing an order has, until now, been able to say
* what it cost but not what it was.
*/
export interface OrderItem {
orderdetailid: number;
orderheaderid: number;
productid: number;
productname: string;
productdescription?: string;
/** What was ordered. A float because some products sell by weight. */
orderqty: number;
/** What the shop could actually supply — less than `orderqty` is a short fill. */
supplyqty?: number;
price: number;
unitname?: string;
taxamount?: number;
discountamount?: number;
/** The line total as Fiesta computed it: quantity, tax and discount applied. */
productsumprice?: number;
itemstatus?: string;
/**
* Always empty today. The column is `gorm:"-"` on the server, so it is
* serialised and never populated — do not build a thumbnail on it.
*/
productimage?: string;
}
/* ────────────────────────────────────────────────────────────────────────────
@@ -93,6 +170,14 @@ export interface TenantInfo {
longitude?: string;
tenantimage?: 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;
minorder?: number;
/** Misspelled on the wire — `applolcationid`, not `applocationid`. */
@@ -101,11 +186,28 @@ export interface TenantInfo {
approved?: number;
moduleid?: number;
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;
lastname?: string;
/** Capitalised on the wire. */
Accountname?: string;
status: string;
/**
* How many outlets this merchant has.
*
* Sent by `getalltenants` only, so it is optional — every other endpoint
* returning a `TenantInfo` leaves it out.
*
* The store list used to work this out for itself, by counting how many
* times a tenantid appeared, on the belief that the endpoint returned one
* row per tenant-location pair. It returns one row per tenant and always
* has, so the count was always 1 and the platform's "Branches" total was
* really its tenant total.
*/
branchcount?: number;
}
export interface TenantLocation {
@@ -170,6 +272,14 @@ export interface CatalogueBrand {
export interface CatalogueRef {
brand: string;
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 +297,66 @@ export interface Product {
/** Capitalised on the wire. */
Subcategoryname?: string;
catalogueid?: number;
/**
* Whether this shop shows the product's health score.
*
* The merchant's decision, made at import and changeable from the product.
* Optional because a backend that predates the column sends nothing — and an
* absent value means YES, which is what every product showed before this
* existed.
*
* Gates the SCORE only. The nutrition figures are shown either way: they are
* what the label states, while the score is a judgement of them.
*/
showhealthscore?: boolean;
productname?: string;
productimage?: string;
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
productimages?: string;
/**
* What the global catalogue said about this product when it was imported —
* a JSON-encoded object, held as a string. Parse with `catalogueFactsOf`.
*
* Carries the fields the product table has no columns for: the FSSAI
* licence, nutrition, highlights, providers, the typical retail range and
* the variant key. The import used to drop all of them and the drawer went
* back to the catalogue on every open, which stopped working the moment a
* re-scrape retired the source row — taking a licence number off a product
* the shop was still selling.
*
* Absent on anything that did not come from the catalogue. The drawer still
* falls back to the live lookup for those.
*/
cataloguefacts?: string;
productdesc?: string;
productsku?: string;
brandid?: number;
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;
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;
taxamount?: number;
taxpercent?: number;
@@ -224,10 +384,23 @@ export interface ProductCategory {
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 {
subcategoryid: number;
subcategoryname: string;
subcatid: number;
subcatname: string;
categoryid?: number;
image?: string;
status?: string;
}
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
@@ -244,6 +417,17 @@ export interface ImportCatalogueProductRequest {
retailprice: number;
productcost: number;
taxpercent: number;
/**
* Whether this shop will show the product's health score.
*
* Omitted means yes. The backend reads an absent field and an explicit
* `false` as different answers on purpose, so a caller that does not know
* about this does not have its imports read as a deliberate no.
*
* Only the score. The nutrition figures are sent either way — they are what
* the label states, while the score is a judgement of them.
*/
showhealthscore?: boolean;
}
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
@@ -273,23 +457,53 @@ export interface ProductStockRequest {
Orders & deliveries — the summary shapes the console reads
──────────────────────────────────────────────────────────────────────────── */
export interface OrderSummary {
totalorders?: number;
delivered?: number;
/**
* What `getordersummary` and `getlocationsummary` actually send.
*
* ── These two used to declare fields the backend has never sent ─────────────
*
* Both carried `totalorders?: number` and `revenue?: number`. Neither exists in
* either response. Verified live on 2026-09-11 against tenant 1147 — the whole
* body is:
*
* {"total":168,"created":84,"pending":1,"processing":0,
* "delivered":1,"cancelled":82,"locationid":1185,"locationname":"R mart "}
*
* The count is `total`, not `totalorders`, and there is no money on it at all.
* Because both were optional AND the interface had an `[key: string]: unknown`
* index signature, `summary.revenue ?? 0` type-checked perfectly and evaluated
* to 0 forever. Four pages read those two names — Reports, both Consoles and
* the platform's store detail — so every "App revenue" and "Online orders"
* figure on this console read ₹0 / 0 against a shop with 84 real orders worth
* ₹25,728 in the same range.
*
* The index signature is gone with them. It is what let the names drift from
* the wire in the first place: with it, a typo and a renamed column are both
* legal, and neither shows up until someone looks at a screen and sees a zero.
*
* Revenue is not here because it is not available here. It comes from the order
* rows — see `branchOrderStats` in `features/store-admin/branchStats.ts`.
*/
interface OrderCounts {
/** Every order in scope, whatever its status. The other counts sum to this. */
total?: number;
created?: number;
pending?: number;
processing?: number;
delivered?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
}
export interface LocationOrderSummary {
export interface OrderSummary extends OrderCounts {
tenantid?: number;
tenantname?: string;
locationid?: number;
locationname?: string;
}
export interface LocationOrderSummary extends OrderCounts {
locationid?: number;
locationname?: string;
totalorders?: number;
delivered?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
}
export interface DeliverySummary {
@@ -389,8 +603,17 @@ export interface OrderRow {
*/
ordervalue?: number;
orderamount?: number;
/** Cash to collect on delivery. Shown only when > 0. */
collectionamt?: number;
/*
* `collectionamt` was declared here and read by three screens. It does not
* exist in Fiesta — `grep -rn "collectionamt" --include=*.go` returns nothing
* — so every one of them showed a dash or zero from the day it was written.
* Removed 2026-09-28 along with those screens.
*
* A type can describe a field the server has never sent, and nothing catches
* it: the optional marker makes `undefined` legal, and `?? 0` turns it into a
* plausible figure. That is the trap, and it is worth remembering before the
* next optional money field is added on the strength of a field name.
*/
deliverycharge?: number;
deliveryamt?: number;
paymenttype?: number;
@@ -412,7 +635,14 @@ export interface OrderRow {
pickupcontactno?: string;
pickupaddress?: 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;
rider?: string;
ridercontactno?: string;
@@ -421,6 +651,97 @@ export interface OrderRow {
pickuptime?: string;
deliverytime?: 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 +917,19 @@ export interface DeliveryRow {
deliverytime?: string;
canceltime?: string;
expecteddeliverytime?: string;
/**
* The delivery window the customer asked for.
*
* Joined from the ORDER — `deliveries` has no window of its own, on purpose,
* so there is one truth about what was asked for. Absent on most rows.
*/
deliveryslotid?: number;
deliveryslotdate?: string;
slotkey?: string;
deliveryslotname?: string;
deliveryslotstart?: string;
deliveryslotend?: string;
itemcount?: number;
orderamount?: number;
pickupcustomer?: string;
@@ -605,6 +939,14 @@ export interface DeliveryRow {
pickupaddress?: string;
pickuplocation?: 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;
deliverycontactno?: string;
deliveryaddress?: string;
@@ -616,8 +958,25 @@ export interface DeliveryRow {
deliverytype?: string;
notes?: 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;
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. */
kms?: string;
actualkms?: string;
@@ -662,6 +1021,19 @@ export interface StaffInfo {
locationid?: number;
locationname?: string;
status?: string;
/**
* Whether they have ever chosen a password.
*
* Every person here is created with an empty one and emailed a link to set it.
* Until that link is used they are in this list, in every branch picker, and
* cannot sign in — and `status` does not say so, because an Active account with
* no password is refused at the login screen like any other.
*
* So `false` is the row that needs an action: a lost invitation, waiting to be
* sent again. Optional because a backend that predates the column sends
* nothing, and the directory then simply does not make the claim.
*/
issetup?: boolean;
}
/**

100
src/api/uploads.test.ts Normal file
View 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
View 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
View 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');
});

View File

@@ -34,6 +34,19 @@ export interface SessionUser {
tenantid: number;
locationid: number;
issuperadmin: boolean;
/**
* The signed session, from the login response.
*
* Optional, and that is the rollout rather than an oversight: a console built
* against a Fiesta that does not issue tokens yet stores nothing here and
* keeps working exactly as before. It becomes required when
* `WEB_AUTH_REQUIRED` is switched on server-side.
*
* Everything else on this record describes the user. This one is the only
* field the server will not take the console's word for — which is the whole
* point of it.
*/
token?: string;
}
/**
@@ -90,7 +103,17 @@ export function toSessionUser(user: FiestaUser): SessionUser {
* sub-path is absorbed there and never reaches the global one.
*/
export const HOME_ROUTE: Record<ConsoleRole, string> = {
'nearle-admin': '/nearle/stores',
// `/nearle/stores` is not a route in this application any more — Nearle's own
// staff have their own, `nearle-platform`. Pointing at it would be exactly
// the failure the comment above describes: the router sends an unknown path
// to `*`, `*` sends it back here, and React Router resolves the loop by
// rendering nothing — a blank page with no console error.
//
// It cannot be reached today, because `login` and `restore` both refuse this
// role before a session exists. It is `/login` rather than that unreachable
// path so that if one of those two checks is ever weakened, the result is a
// sign-in screen rather than a white screen nobody can diagnose.
'nearle-admin': '/login',
'store-admin': '/admin/console',
'store-manager': '/store/console',
};

112
src/auth/session.test.ts Normal file
View 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`);
}
});

View File

@@ -1,25 +1,56 @@
/**
* Sign-in and session persistence.
*
* There is no token to hold. `TenantWebLogin` returns the user record and
* nothing else, so the session IS that record. It is kept in sessionStorage
* rather than localStorage: a shared back-office machine should not stay signed
* in after the browser closes, and there is no server-side session to revoke.
* The session is the user record plus, now, a signed token. Until Fiesta grew
* `middleware.WebAuth` there was no token to hold: login returned the record and
* nothing else, the console asserted its own `tenantid` on every request, and
* the server believed it. The record is still what the app renders from; the
* token is the only part the server will not take our word for.
*
* Kept in sessionStorage rather than localStorage: a shared back-office machine
* should not stay signed in after the browser closes. That also means the tab
* closing is what normally ends a session — the token's own expiry is a
* backstop for a tab left open, not the mechanism.
*
* The storage key lives in `./token`, which the HTTP client also reads. It has
* to sit under both: this file calls the API to sign in, and the client needs
* the token to make that call authorised, so neither can import the other.
*/
import { api, WEB } from '@/api/client';
import type { FiestaUser } from '@/api/types';
import { toSessionUser, type SessionUser } from './roles';
import { SESSION_STORAGE_KEY } from './token';
import { isAllowedHere, wrongConsoleMessage } from './workspace';
const STORAGE_KEY = 'nearle.session.v1';
/**
* Thrown when the credentials were right but the account belongs to the other
* console.
*
* Its own type so the login screen can present it as an answer rather than a
* failure: nothing went wrong, the person is at the wrong door. It reads
* differently from "that password is not right", and showing it in the same red
* as a bad password would send somebody to reset a password that is fine.
*/
export class WrongConsoleError extends Error {
constructor(message: string) {
super(message);
this.name = 'WrongConsoleError';
}
}
/** Thrown when the account exists but has never had a password set. */
/**
* Thrown when the account exists but has never had a password set.
*
* It carried the userid, for the setup form that used to be on the login screen.
* Both are gone: a userid was all it took to set any account's password, and the
* probe below handed one to anybody who typed an email. The signed invitation
* replaced it, so there is nothing left for this to carry.
*/
export class PasswordSetupRequiredError extends Error {
readonly userid: number;
constructor(userid: number) {
constructor() {
super('This account needs a password before it can sign in.');
this.name = 'PasswordSetupRequiredError';
this.userid = userid;
}
}
@@ -56,23 +87,61 @@ const CONFIG_ID = 1;
export async function login(email: string, password: string): Promise<SessionUser> {
const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
const envelope = await api.envelope<FiestaUser & { setup?: boolean; userid?: number }>(
`${WEB}/users/applogin`,
{ method: 'POST', body },
);
const envelope = await api.envelope<FiestaUser & { setup?: boolean }>(`${WEB}/users/applogin`, {
method: 'POST',
body,
});
// A brand-new account — `createtenantlocation` spawns branch logins with an
// empty password — answers `status: true` with a 409 and the userid to set
// one against. It is not a failure, it is the first step.
// empty password — answers `status: true` with a 409. Not a failure: the
// account is real and is waiting for its invitation to be used.
if (envelope.code === 409 && envelope.details?.setup === true) {
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
throw new PasswordSetupRequiredError();
}
if (envelope.status !== true || !envelope.details) {
throw new Error(loginMessage(envelope.code, envelope.message));
}
const session = toSessionUser(envelope.details);
// The token rides on the envelope, not on `details` — it is not a fact about
// the user, it is what proves a later request is theirs.
//
// Refused when absent, rather than stored and hoped for. `attachWebSession`
// on the server logs a minting failure and returns the user record anyway,
// which was correct while WEB_AUTH_REQUIRED was off and is a broken console
// now that it defaults on: the sign-in succeeds, the shell renders, and every
// request after it goes out with no `authorization` header and comes back
// 401 with nothing on screen to say why.
//
// Failing here names the problem at the moment it happens, to the person best
// placed to report it, instead of scattering 401s across every page.
if (!envelope.token) {
throw new Error(
'Signed in, but the server did not issue a session. Nothing would load — ' +
'tell your administrator the API could not mint a session token.',
);
}
const session = { ...toSessionUser(envelope.details), token: envelope.token };
/*
* The wrong console, refused before anything is written down.
*
* Nearle's staff sign in at the platform site and merchants at the merchant
* one, and neither may sign in at the other. The role is not known until the
* password has been checked — `applogin` returns it — so the refusal can only
* happen here, after credentials are verified and before a session exists.
*
* The ORDER is the whole point. `persist` used to run first, so refusing
* afterwards would leave a valid session on this origin belonging to somebody
* with no routes to reach: signed in by every measure the shell uses, with a
* nav built from a role this build does not serve. Throwing before the write
* leaves storage untouched and the login screen exactly as it was.
*/
if (!isAllowedHere(session.role)) {
throw new WrongConsoleError(wrongConsoleMessage(session.role));
}
persist(session);
return session;
}
@@ -99,7 +168,7 @@ export async function login(email: string, password: string): Promise<SessionUse
*
* 409 + status false → no such account ("Invalid Email")
* 403 → account deactivated
* 409 + status true → exists, no password set (carries the userid)
* 409 + status true → exists, no password set
* 401 + status true → exists, has a password ("Password is required")
*
* The last one is the whole trick: a password-less attempt against a real
@@ -112,16 +181,29 @@ export async function login(email: string, password: string): Promise<SessionUse
* password, so the probe reveals nothing that was not already available with
* one more field filled in.
*/
export type AccountCheck = { state: 'password' } | { state: 'setup'; userid: number };
/**
* `setup` no longer carries a userid, and that is the point.
*
* It used to, and the pair of that and `setpassword` taking a bare userid was
* an account takeover: this probe answers an email with NO password, so anyone
* could POST a merchant's primary address — usually printed on their shopfront
* — receive their userid, set a password and own the business. The server has
* stopped returning it and stopped accepting it.
*
* So `setup` now means only "this account exists and has never been used". The
* way in is the invitation emailed at onboarding, and the screen says so rather
* than offering a form.
*/
export type AccountCheck = { state: 'password' } | { state: 'setup' };
export async function checkAccount(email: string): Promise<AccountCheck> {
const envelope = await api.envelope<{ setup?: boolean; userid?: number }>(
`${WEB}/users/applogin`,
{ method: 'POST', body: { authname: email.trim(), configid: CONFIG_ID } },
);
const envelope = await api.envelope<{ setup?: boolean }>(`${WEB}/users/applogin`, {
method: 'POST',
body: { authname: email.trim(), configid: CONFIG_ID },
});
if (envelope.code === 409 && envelope.details?.setup === true) {
return { state: 'setup', userid: envelope.details.userid ?? 0 };
return { state: 'setup' };
}
// "Password is required" — the account is real and has one. Exactly what we
// wanted to learn, arriving as a refusal.
@@ -137,27 +219,48 @@ export const MIN_PASSWORD_LENGTH = 6;
/**
* Sets the password on an account that has never had one.
*
* `PUT /users/update` doubles as the password call. There is no dedicated
* endpoint and no reset flow — the controller says so in as many words
* (`userController.go:145`): "this endpoint also doubles as the
* password-setup/reset call (userid + password only, everything else left zero
* so GORM's `Updates` skips it)". Sending only those two fields is therefore
* load-bearing: a struct with any other field populated would write it.
* `POST /users/setpassword`, which is public — it has to be. This runs when
* nobody is signed in and cannot be: the account has no password yet, so there
* is no way to obtain a session first.
*
* This is reachable only with the `userid` that `applogin` just handed back for
* an account it confirmed has an empty password. It is not a "change my
* password" call and must not be wired up as one — nothing here verifies the
* old password, because there is no old password.
* It used to call `PUT /users/update`, which doubles as a password write but
* sits behind the session guard. Once `WEB_AUTH_REQUIRED` began defaulting on,
* that returned "a session token is required; sign in again" to somebody who
* could not sign in — sign-in needs a password, and setting the password needed
* a sign-in. Every branch login created with an empty password was unusable.
*
* The server refuses this on any account that already HAS a password, which is
* what makes leaving it open safe. It is a setup call, never a reset — nothing
* here verifies an old password, because there is no old password.
*
* ── Why it takes a token and not a userid ───────────────────────────────────
*
* It took a userid, which `checkAccount` obtained by POSTing an email with no
* password. That pair was an account takeover: read a merchant's primary
* address off their shopfront, POST it, receive their userid, set a password,
* own the business. No guessing at any step, and the empty-password check was
* no defence — an un-set-up account is exactly what such an attacker wants.
*
* The invitation emailed at onboarding replaced it. The userid lives inside a
* signature the server produced, so knowing an email is no longer enough and
* neither is knowing a userid. This is now reached only from `/set-password`,
* with a token out of the link.
*
* Passwords are stored in clear on this backend. That is not something the
* console can fix, and it is the reason this flow exists at all rather than an
* emailed setup link.
* console can fix.
*/
export async function setInitialPassword(userid: number, password: string): Promise<void> {
export async function setInitialPassword(token: string, password: string): Promise<void> {
if (password.length < MIN_PASSWORD_LENGTH) {
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
}
await api.put<unknown>(`${WEB}/users/update`, { userid, password });
if (!token.trim()) {
// Reached when somebody opens /set-password with no `t` in the URL — a
// truncated link, or a copy that lost the query string. Said here so the
// screen can explain it rather than the server answering "not an
// invitation link" to a request that was never going to work.
throw new Error('This link is incomplete. Use the full link from your invitation email.');
}
await api.post<unknown>(`${WEB}/users/setpassword`, { token, password });
}
/**
@@ -184,17 +287,43 @@ function loginMessage(code: number | undefined, message: string | undefined): st
}
export function persist(session: SessionUser): void {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session));
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
}
export function restore(): SessionUser | null {
const raw = sessionStorage.getItem(STORAGE_KEY);
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as SessionUser;
// A stored blob is only as trustworthy as the tab it came from; a shape
// check keeps a corrupted value from crashing the shell on boot.
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
// A session without a token is not a session.
//
// This used to be restored happily, and the result was the worst state the
// console can be in: signed in by every visible measure — name in the
// corner, nav rendered, pages mounted — and unable to make a single
// authenticated request, because `authHeader()` had nothing to send. Every
// call came back 401 and nothing on screen explained why. A PUT to
// `users/update` on 2026-09-25 went out with no `authorization` header at
// all, which is what sent us looking.
//
// It happens whenever login could not mint one: `attachWebSession` logs the
// failure and returns the user record anyway, which was right while
// WEB_AUTH_REQUIRED was off and is a broken console now that it defaults on.
// A stored session predating the token also lands here.
//
// Returning null sends them to the sign-in screen, which is a state people
// know what to do with.
if (typeof parsed.token !== 'string' || parsed.token.trim() === '') return null;
// The same refusal as sign-in, applied to what is already in storage.
//
// A session stored before the two consoles were split, or one held on a
// site whose workspace flag has since changed, belongs to somebody this
// build serves no routes for. Restoring it renders a shell with a nav built
// from a role that has nowhere to go — so it is dropped and they are asked
// to sign in, which is where they learn which console is theirs.
if (!isAllowedHere(parsed.role)) return null;
return parsed;
} catch {
return null;
@@ -202,5 +331,5 @@ export function restore(): SessionUser | null {
}
export function clear(): void {
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(SESSION_STORAGE_KEY);
}

81
src/auth/token.ts Normal file
View 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.
}
}

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

View File

@@ -1,4 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { isStaleChunkError } from '@/lib/staleChunk';
/**
* The last line before a white page.
@@ -45,12 +46,28 @@ export class ErrorBoundary extends Component<Props, State> {
this.setState({ error: null });
};
private reload = () => {
window.location.reload();
};
override render() {
const { error } = this.state;
if (!error) return this.props.children;
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 (
<div
role="alert"
@@ -58,18 +75,30 @@ export class ErrorBoundary extends Component<Props, State> {
margin: '48px auto',
maxWidth: 620,
padding: '28px 32px',
borderRadius: 16,
borderRadius: 'var(--card-radius)',
border: '1px solid #F1D3D3',
background: '#FFFBFB',
fontFamily: 'inherit',
}}
>
<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>
<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>
<pre
style={{
@@ -88,10 +117,13 @@ export class ErrorBoundary extends Component<Props, State> {
</pre>
<button
type="button"
onClick={this.reset}
onClick={isStale ? this.reload : this.reset}
style={{
padding: '9px 18px',
borderRadius: 999,
/* The same corner as every other control on the console. A stadium
here made the one button a person sees on a broken screen the one
button shaped unlike the rest of the product. */
borderRadius: 'var(--card-radius-sm, 10px)',
border: '1px solid #D9C0C0',
background: '#FFFFFF',
fontSize: 14,
@@ -99,7 +131,7 @@ export class ErrorBoundary extends Component<Props, State> {
cursor: 'pointer',
}}
>
Try again
{isStale ? 'Reload the page' : 'Try again'}
</button>
</div>
);

View File

@@ -1,107 +1,89 @@
import type { ReactNode } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import './kpiCard.css';
/**
* A KPI tile — the console's headline figure.
*
* ── There were two of these, and neither worked ─────────────────────────────
*
* This component put an icon inline with the label and CENTRED the value
* underneath it, which is why a row of tiles never lined up: a centred number
* sits in a different place in every tile depending on how long it is.
* `console.css` had a second, unrelated implementation — `.kpi` — with a
* brand-tinted icon tile on the left and the text stacked beside it. The two
* appeared on the same screen. The second was the better design and this is
* now that design, for both.
*
* ── `note` is rendered again, and that is the real change ───────────────────
*
* The line under the figure — "142 app · 38 counter", "12% of app orders" —
* was removed from the tile at some point but never removed from the CALLERS:
* 86 of them still compute and pass it, and it was being thrown away. That is
* the half of a KPI that carries the meaning. A bare `0` under "Unsynced
* bills" reads as "no data" when it means everything is in the books, and `42`
* under "Total orders" says nothing about which channel they came through.
*
* ── `tone` does something now ───────────────────────────────────────────────
*
* All five tones used to map to `var(--color-brand)`, so the prop was
* decorative — every tile was purple whether it reported takings or a failure.
* The tone now colours the icon tile only: the figure itself stays in ink,
* because a number that changes colour with its own value is hard to compare
* against the tile beside it. Brand remains the default, so a tile that is
* merely reporting is not shouting.
*/
export type KpiTone = 'accent' | 'success' | 'warning' | 'error' | 'neutral';
const TONE_COLOR: Record<KpiTone, string> = {
accent: 'var(--color-brand)',
success: 'var(--color-brand)',
warning: 'var(--color-brand)',
error: 'var(--color-brand)',
neutral: 'var(--color-brand)',
};
export interface KpiCardProps {
/** Small-caps label. Say what it is, not what it means. */
label: string;
value: string;
/**
* The sub-pill. This is where the number gets its meaning — "1 of 84 orders"
* says something "1.2%" does not.
* The line under the figure — what the number is made of, or what it is a
* share of. This is where a KPI gets its meaning; see the note above.
*/
note?: string;
/**
* Colours the icon tile. `neutral` and `accent` are both the brand — a tile
* reports by default and only says more when something is actually wrong.
*/
tone?: KpiTone;
icon?: ReactNode;
/**
* Accepted and ignored.
*
* The underline bar this used to drive was removed from the tile. Six call
* sites still pass it, so the prop stays declared to keep them compiling —
* it is not read. Either drop it from the callers or restore the bar; right
* now it is neither, and this comment is here so that is visible.
* The 2px underline bar this drove was removed from the tile deliberately —
* a proportion bar under five tiles turned the strip into a chart. Six call
* sites still pass it, so the prop stays declared to keep them compiling.
* Drop it from those callers and this can go.
*/
fill?: number;
}
/**
* A KPI tile.
*
* Label and icon, a large tabular value, a muted sub-pill carrying the
* interpretation, and a 2px underline in the tile's tone. Sized to sit 4–6
* across rather than 4 — a console is glanced at all day, not read once.
*/
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
const color = TONE_COLOR[tone];
return (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
{/* Icon first, then the label; the pill sits opposite it. Both are
fixed to this row so tiles line up whether or not they carry one. */}
{/* A fixed height, so a label that wraps to two lines (a long note
squeezes it) does not push that tile's value below its neighbours'. */}
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
{icon ? (
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
{icon}
</span>
) : null}
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
>
{label}
</Text>
</HStack>
<div className="kpi-card" data-tone={tone}>
{/* Label and icon share the top row; the figure gets the full width of
the card underneath them.
{note ? (
<span
style={{
flex: 'none',
maxWidth: '58%',
background: 'var(--color-slate-100)',
color: 'var(--color-slate-500)',
borderRadius: 12,
padding: '2px 8px',
fontSize: 10.5,
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{note}
</span>
) : null}
</HStack>
The icon used to sit in a column of its own to the LEFT, and at the
width these tiles actually get — five across a 1,224px column, less
340px whenever Nearle Buddy is open — a 40px tile plus its gutter
left about 134px for the number. "₹1,24,500" does not fit in 134px at
24px bold, so it wrapped, and it wrapped mid-figure: the first tile
on the Console read "₹1,24,50" on one line and "0" on the next. */}
<div className="kpi-card-head">
<span className="kpi-card-label">{label}</span>
{icon ? (
<span className="kpi-card-icon" aria-hidden>
{icon}
</span>
) : null}
</div>
<HStack justify="center" align="center" style={{ flex: 1 }}>
<Text
as="div"
hasTabularNumbers
style={{ color, fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, lineHeight: 1.1 }}
>
{value}
</Text>
</HStack>
</VStack>
</Card>
<span className="kpi-card-value">{value}</span>
{note ? <span className="kpi-card-note">{note}</span> : null}
</div>
);
}

View File

@@ -1,24 +1,34 @@
import type { ReactNode } from 'react';
import { StickyRow } from './StickyRow';
export interface PageHeaderProps {
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. */
actions?: ReactNode;
/** An optional tabs row directly under the header rule. */
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.
*
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
* rhythm.
* Actions right-aligned and wrapping, with an optional tabs row beneath.
* Everything the page stacks below sits on a 24px 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
* and another 12px below before the tabs — 28px of nothing plus a line, on
@@ -30,88 +40,68 @@ export interface PageHeaderProps {
*
* The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again.
*
* ── Held under the nav bar ──────────────────────────────────────────────────
*
* This row does not scroll away. It used to: reaching the bottom of a long list
* put the tab you were in, the search and every action button off the top of
* the window, so the only way back to the controls over what you were reading
* was to scroll back through all of it. The page still scrolls normally —
* nothing here gets a scrollbar of its own — the row simply stays. The
* behaviour and the canvas it paints live in `StickyRow` and `.page-sticky`.
*/
export function PageHeader({ title, 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 (
<>
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h1
className="page-title"
{/*
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
style={{
margin: 0,
fontFamily: 'var(--font-display)',
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
{title}
</h1>
{/* Left of the row when inline, so the tabs start at the page's
left edge and the actions stay on the right. */}
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
{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>
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
) : null}
</div>
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
{hasTabsRow ? <div>{tabs}</div> : null}
</StickyRow>
) : null}
</>
);
}

139
src/components/Panel.tsx Normal file
View 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;
}

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

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

View File

@@ -66,8 +66,8 @@ export function SheetDropzone({
alignItems: 'center',
gap: 14,
padding: '14px 16px',
borderRadius: 14,
border: '1px solid var(--color-line)',
borderRadius: 'var(--card-radius)',
border: 'var(--card-border)',
background: 'var(--color-surface-subtle)',
}}
>
@@ -153,7 +153,7 @@ export function SheetDropzone({
alignItems: 'center',
gap: 8,
padding: '16px 20px',
borderRadius: 13,
borderRadius: 'var(--card-radius)',
border: `1.5px dashed ${
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
}`,

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

View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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
View 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
View 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%; }
}

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

View File

@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
import { AssistantPanel } from './AssistantPanel';
import { DateScopePicker } from './DateScope';
export interface NavEntry {
to: string;
@@ -59,6 +60,15 @@ export interface AppShellProps {
* closed.
*/
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,
manageItems,
headerActions,
banner,
}: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
@@ -94,6 +105,29 @@ export function AppShell({
const isMobile = useIsMobile();
const menuRef = useRef<HTMLDivElement>(null);
/*
A new page starts at the top.
A single-page app does not reload, so the document keeps whatever scroll
offset the last page was left at: read Sales down to row 40, click Inventory,
and Inventory opens 2,000px down — usually past everything it has, so it
looks empty. Nothing in the app was resetting this.
INSTANT, deliberately, and it is the one place in the console that opts out
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
animate those 2,000px on every navigation, which means a second of the new
page flying past before it settles — the page arriving late, rather than the
page arriving. Smooth is for a move you asked for within a page; a route
change is not one.
Keyed on `pathname` alone, not on the whole location: the branch scope and
the date range travel in the query string, and re-scoping a table you are
halfway down should leave you where you were.
*/
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
// Escape closes the account menu. A menu that can only be dismissed by
// finding the trigger again is a trap for anyone on a keyboard, and this one
// sits over the page rather than beside it.
@@ -123,14 +157,26 @@ export function AppShell({
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
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
style={{
position: 'sticky',
top: 0,
zIndex: 40,
background: 'rgba(255,255,255,.85)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
background: 'var(--color-surface)',
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
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}
{/* 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
row from md where it becomes a side column. */}
{banner}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
{/* Scoped to the page, not the shell: a page that throws should leave

View File

@@ -1,5 +1,14 @@
import { useRef, useState } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { errorMessage } from '@/api/client';
import {
approveAssistant,
askAssistant,
assistantAvailable,
type AssistantAnswer,
} from '@/api/assistant';
import { useAssistantScope } from './assistantScope';
import { matchAssistantRoute } from './assistantContext';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import {
DEFAULT_WIDTH,
@@ -9,114 +18,313 @@ import {
useAssistantWidth,
} from './assistantWidth';
/** Per-route context, so the panel knows which page it is sitting beside. */
const CONTEXT: Record<
string,
{ page: string; title: string; greeting: string; reading: string; prompts: string[] }
> = {
'/nearle/stores': {
page: 'Stores',
title: 'Nearle Buddy',
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
prompts: [
'Which tenants have no branches?',
'Who onboarded most recently?',
'What needs attention?',
'Summarise platform growth',
],
},
'/nearle/onboard/tenant': {
page: 'Onboard tenant',
title: 'Provisioning a tenant',
greeting: 'Registering the enterprise also creates its primary Administrator account.',
reading: 'Would cover the onboarding form — required fields, category and head-office address.',
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
},
'/nearle/catalogue': {
page: 'Global catalogue',
title: 'Stocking a store',
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
reading: 'Would cover the global catalogue and what this tenant has already imported.',
prompts: [
'Which products are already imported?',
'Catalogue or spreadsheet?',
'What does re-importing do?',
'Which columns does the sheet need?',
],
},
/** One question and what came back. `answer` and `error` are exclusive. */
interface Exchange {
question: string;
answer?: AssistantAnswer;
error?: string;
/**
* What became of a change Buddy proposed.
*
* `undefined` means the card is still on screen waiting. Once decided the
* card is replaced by its outcome and cannot be pressed again — a card that
* stayed live after approval is a second write waiting to happen.
*/
decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string };
}
/* ── Store Admin ──────────────────────────────────────────────────────── */
/** Replaces the last entry, which is always the one in flight. */
function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] {
if (thread.length === 0) return thread;
return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry));
}
'/admin/console': {
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
prompts: [
'Which branch is underperforming?',
'Any tills not syncing?',
'What needs my approval?',
'Where is stock running out?',
],
},
'/admin/sales': {
page: 'Sales',
title: 'Orders and deliveries',
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
prompts: [
'Why is the cancel rate high?',
'Which orders are stuck?',
'Compare online and counter sales',
'What is out for delivery?',
],
},
'/admin/inventory': {
page: 'Inventory',
title: 'Catalogue and stock',
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
prompts: [
'What is waiting for approval?',
'Which products are unpublished?',
'What is low or out of stock?',
'How do I upload a product sheet?',
],
},
'/admin/users': {
page: 'Users & access',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Would cover your back-office directory and the till accounts at each branch.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
'/admin/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Compare my branches',
'Online versus counter revenue',
'What is my inventory worth?',
],
},
};
/**
* What the composer says when it cannot be used, and why.
*
* Three different states with three different sentences. "Not connected yet"
* for everything would be true and useless: a person whose deployment has no
* model can do nothing, but a person on the Inventory page can move to Sales
* and get an answer today.
*/
export function composerHint(
available: boolean | null,
hasAgent: boolean,
isAsking: boolean,
): string {
if (isAsking) return 'Thinking…';
if (available === null) return 'Checking…';
if (available === false) return 'Not connected yet';
if (!hasAgent) return 'No assistant for this page yet';
return 'Ask about this page';
}
const FALLBACK = {
page: 'Console',
title: 'Good afternoon',
greeting: 'Ask about anything on this page.',
reading: 'Would cover this page.',
prompts: ['What needs attention?', 'Summarise this page'],
};
/**
* The line under the composer, or nothing.
*
* This used to be one hardcoded sentence — "Not connected yet — there is no
* assistant service behind this panel." — written before the panel had an API
* behind it and never removed once it did. It rendered on every page in every
* state, so the panel told everybody Buddy was off, permanently, including on a
* deployment where Buddy was answering questions. It also flatly contradicted
* the placeholder three lines above it, which was the only part telling the
* truth.
*
* The lesson worth keeping: a status message with no condition attached is not
* a status message. Each branch here reads the same two facts the composer
* itself is disabled by, so the two cannot drift apart again.
*/
export function composerNote(available: boolean | null, hasAgent: boolean): string {
// Still asking. A sentence that appears for 200ms and is replaced reads as a
// flicker, so the space stays empty until there is something true to put in it.
if (available === null) return '';
if (available === false) return 'Not connected yet — there is no assistant service behind this panel.';
if (!hasAgent) return 'No assistant for this page yet. Try Console or Sales.';
// Buddy is working. The caveat that matters then is not about connection: it
// is that an answer is a model reading live rows, and the rows are the thing
// to act on.
return 'Buddy reads your live data. Check anything you act on.';
}
/**
* A change waiting on the person.
*
* Framed and set apart from the reply on purpose. The sentence above it was
* written by a model; this was resolved by the server against the database, and
* the two must not read as one thing. What is shown here — the ids, the names,
* the quantity — is what the button actually agrees to.
*
* Once decided the buttons are gone, replaced by what happened. A card that
* stayed pressable after approval is a second write waiting for a double-click.
*/
function ApprovalCard({
proposal,
decision,
onDecide,
}: {
proposal: NonNullable<AssistantAnswer['awaiting']>;
decision: Exchange['decision'];
onDecide: (approve: boolean) => void;
}) {
const busy = decision?.state === 'approving';
const outcomeColour =
decision?.state === 'failed'
? 'var(--color-error, #d64545)'
: decision?.state === 'done'
? 'var(--color-success, #1f9d55)'
: 'var(--color-ink-3)';
return (
<div
style={{
border: '1px solid var(--color-line)',
borderRadius: 12,
padding: '11px 12px',
background: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<p style={{ margin: 0, fontSize: 12.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{proposal.summary}
</p>
{proposal.details?.length ? (
<dl style={{ margin: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 10px' }}>
{proposal.details.map((detail) => (
<Fragment key={detail.label}>
<dt style={{ fontSize: 11.5, color: 'var(--color-ink-3)' }}>{detail.label}</dt>
<dd style={{ margin: 0, fontSize: 11.5, color: 'var(--color-ink-1)' }}>
{detail.value}
</dd>
</Fragment>
))}
</dl>
) : null}
{proposal.warning ? (
<p
style={{
margin: 0,
fontSize: 11.5,
lineHeight: 1.5,
color: 'var(--color-warning, #b7860b)',
}}
>
{proposal.warning}
</p>
) : null}
{decision === undefined ? (
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => onDecide(true)}
style={{
flex: 1,
padding: '7px 10px',
borderRadius: 8,
border: 0,
background: 'var(--color-brand)',
color: '#fff',
fontSize: 12.5,
fontWeight: 600,
cursor: 'pointer',
}}
>
Approve
</button>
{/* "Not now", not "Reject". Nothing is refused and nothing is
recorded — the card is simply left alone, and it expires unused. */}
<button
type="button"
onClick={() => onDecide(false)}
style={{
padding: '7px 12px',
borderRadius: 8,
border: '1px solid var(--color-line)',
background: 'transparent',
color: 'var(--color-ink-2)',
fontSize: 12.5,
cursor: 'pointer',
}}
>
Not now
</button>
</div>
) : (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: outcomeColour }}>
{busy
? 'Making the change…'
: decision.state === 'dismissed'
? 'Left alone. Nothing was changed.'
: (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))}
</p>
)}
</div>
);
}
/**
* One question and its answer.
*
* The answer shows what it ran, not just what it concluded. Buddy states things
* with the confidence of a sentence, and the only honest way to present that is
* beside the tools it used and a link to the page holding the same rows — so a
* person can disagree with it.
*/
function Exchange({
entry,
isLast,
isAsking,
onDecide,
}: {
entry: Exchange;
isLast: boolean;
isAsking: boolean;
onDecide: (approve: boolean) => void;
}) {
const waiting = isLast && isAsking && !entry.answer && !entry.error;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p
style={{
margin: 0,
alignSelf: 'flex-end',
maxWidth: '85%',
padding: '7px 11px',
borderRadius: '14px 14px 4px 14px',
background: 'var(--color-brand-tint, rgba(102,37,130,.08))',
color: 'var(--color-ink-1)',
fontSize: 13,
lineHeight: 1.55,
}}
>
{entry.question}
</p>
{waiting ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--color-ink-3)' }}>Looking…</p>
) : null}
{entry.error ? (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-error, #d64545)' }}>
{entry.error}
</p>
) : null}
{entry.answer ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.6,
color: 'var(--color-ink-1)',
whiteSpace: 'pre-wrap',
}}
>
{entry.answer.reply}
</p>
{/* Said plainly, not as a subtle grey hint. A partial answer that
looks complete is the failure this flag exists to prevent. */}
{entry.answer.incomplete ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: 'var(--color-warning, #b7860b)' }}>
This answer is partial — Buddy ran out of room before finishing.
</p>
) : null}
{entry.answer.used?.length ? (
<p style={{ margin: 0, fontSize: 11, lineHeight: 1.5, color: 'var(--color-ink-4)' }}>
{entry.answer.used
.map((step) =>
step.outcome === 'ok'
? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}`
: `${step.tool} · refused`,
)
.join(' ')}
</p>
) : null}
{entry.answer.awaiting ? (
<ApprovalCard
proposal={entry.answer.awaiting}
decision={entry.decision}
onDecide={onDecide}
/>
) : null}
{entry.answer.sources?.length ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
{entry.answer.sources.map((source) => (
<a
key={source}
href={source}
style={{ color: 'var(--color-brand)', textDecoration: 'none', marginRight: 10 }}
>
See the rows
</a>
))}
</p>
) : null}
</div>
) : null}
</div>
);
}
/**
* Typed against `CONTEXT`'s own shape so the two cannot drift.
*
* Without this the fallback has no `agent` key at all, and `routeContext` —
* which is one or the other — loses the field entirely. A page that fell
* through to the fallback would then be a compile error rather than simply a
* page with no assistant, which is what it is.
*/
/**
* Nearle Buddy, as a layout column.
@@ -142,11 +350,151 @@ export function AssistantPanel({
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const threadRef = useRef<HTMLDivElement>(null);
const { width, setWidth, reset } = useAssistantWidth();
const [isDragging, setDragging] = useState(false);
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
const { key, context: routeContext } = matchAssistantRoute(pathname);
/**
* 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),
@@ -246,6 +594,35 @@ export function AssistantPanel({
)}
</div>
{/*
The scrolling part, and the only one.
Everything above this is chrome that stays put; everything below it is
the composer, which must never leave the screen. Before this existed
the panel was one `height: 100%` column with no overflow anywhere: a
thread longer than the panel pushed the chips and the composer past
the bottom edge, and there was nothing to scroll — the box you type
into simply left the screen and the conversation could not be read
back.
`minHeight: 0` is the load-bearing half. A flex child defaults to
`min-height: auto`, which means it refuses to shrink below its content,
so `flex: 1` alone would let this grow to the height of the whole
conversation and overflow the panel exactly as before — the scrollbar
never appears and nothing looks wrong in the code.
*/}
<div
ref={threadRef}
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overscrollBehavior: 'contain',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* Greeting — top-aligned, because it is the first thing in a
conversation, not a splash screen. */}
<div>
@@ -285,25 +662,58 @@ export function AssistantPanel({
</p>
</div>
<div style={{ flex: 1 }} />
{/* The conversation. Inside the scroller with the greeting above it, so
a short thread reads straight on from the greeting and a long one
scrolls under it — with the chips and the composer staying put. */}
{thread.length > 0 ? (
<div
role="log"
aria-label="Conversation with Nearle Buddy"
aria-busy={isAsking}
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
>
{thread.map((entry, index) => (
<Exchange
key={index}
entry={entry}
isLast={index === thread.length - 1}
isAsking={isAsking}
onDecide={(approve) => void decide(index, approve)}
/>
))}
</div>
) : null}
</div>
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
is a bug no amount of fade masking fixes. */}
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{context.prompts.map((prompt) => (
<Chip key={prompt} label={prompt} onSelect={() => setDraft(prompt)} />
<Chip
key={prompt}
label={prompt}
/* A chip sends when Buddy can answer, and only fills the box when it
cannot — so a chip on a page with no assistant still shows what
could be asked rather than doing nothing on click. */
onSelect={() => (canAsk ? void send(prompt) : setDraft(prompt))}
/>
))}
</div>
{/* Disabled, not merely inert.
{/* Live, at last — but only when something can actually answer.
This composer used to accept text and enable a brand-purple send
button the moment you typed — then swallow the submit. There is no
assistant endpoint anywhere in `src/api`. A disabled field says the
same thing honestly and costs nobody a message they thought they
sent. */}
button the moment you typed, then swallow the submit, because no
assistant endpoint existed. It was disabled rather than left to lie.
It is enabled here exactly when a model is configured AND this page
has an agent, and the placeholder says which of the two is missing
otherwise. The rule it keeps is the old one: never accept a message
nothing will read. */}
<form
onSubmit={(event) => event.preventDefault()}
onSubmit={(event) => {
event.preventDefault();
void send(draft);
}}
style={{
display: 'flex',
alignItems: 'flex-end',
@@ -330,9 +740,18 @@ export function AssistantPanel({
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled
placeholder="Not connected yet"
aria-label="Ask Nearle Buddy — not connected yet"
onKeyDown={(event) => {
// Enter sends, shift+Enter writes a second line. The composer is
// one line tall most of the time, so requiring the button would
// make every question a two-action job.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void send(draft);
}
}}
disabled={!canAsk}
placeholder={composerHint(available, Boolean(agent), isAsking)}
aria-label={`Ask Nearle Buddy — ${composerHint(available, Boolean(agent), isAsking)}`}
style={{
display: 'block',
width: '100%',
@@ -351,18 +770,19 @@ export function AssistantPanel({
<button
type="submit"
aria-label="Send message"
disabled
disabled={!canAsk || draft.trim() === ''}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: 0,
flex: 'none',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-4)',
background:
canAsk && draft.trim() !== '' ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: canAsk && draft.trim() !== '' ? '#fff' : 'var(--color-ink-4)',
display: 'grid',
placeItems: 'center',
cursor: 'default',
cursor: canAsk && draft.trim() !== '' ? 'pointer' : 'default',
transition: 'background .2s',
}}
>
@@ -370,17 +790,19 @@ export function AssistantPanel({
</button>
</form>
<p
style={{
margin: 0,
fontSize: 10,
lineHeight: 1.4,
color: 'var(--color-ink-4)',
textAlign: 'center',
}}
>
Not connected yet — there is no assistant service behind this panel.
</p>
{composerNote(available, Boolean(agent)) ? (
<p
style={{
margin: 0,
fontSize: 10,
lineHeight: 1.4,
color: 'var(--color-ink-4)',
textAlign: 'center',
}}
>
{composerNote(available, Boolean(agent))}
</p>
) : null}
</div>
</aside>
);

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

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

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

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

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

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

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

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

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

View File

@@ -1,4 +1,4 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import {
AlertCircle,
@@ -8,17 +8,12 @@ import {
Loader2,
Lock,
Mail,
ShieldCheck,
Building2,
Sparkles,
} from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import {
checkAccount,
MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError,
setInitialPassword,
} from '@/auth/session';
import { checkAccount, PasswordSetupRequiredError, WrongConsoleError } from '@/auth/session';
/**
* Sign-in — KROW's full-bleed auth archetype.
@@ -41,33 +36,61 @@ export function LoginPage() {
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
/* Separate from `error`: the wrong console is guidance, not a failure. So is
an account that has not been set up — the way in is the invitation email,
and nothing about that is broken. */
const [notice, setNotice] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
/**
* Which of the three steps is on screen.
* Which of the two steps is on screen.
*
* Email first, always. A tenant made by `createtenantuser` and every branch
* made by `createtenantlocation` is spawned with an EMPTY password, so their
* owner's first sign-in cannot succeed — and a form that asks for the
* password up front asks them for something that does not exist yet. They
* guess, it fails, and only then are they told to invent one.
* guess, it fails, and only then are they told what is actually wrong.
*
* So the email is checked before a password field is ever shown, and the page
* goes straight to whichever step that account actually needs. This is what
* the old console does, and it is the right shape.
* So the email is checked before a password field is ever shown. An account
* that has no password never reaches the second step: it is told to use its
* invitation, because setting a first password happens on `SetPasswordPage`
* behind a signed link and nowhere else.
*/
const [step, setStep] = useState<'email' | 'password' | 'setup'>('email');
const [step, setStep] = useState<'email' | 'password'>('email');
/**
* The userid the probe returned for an account with no password.
*
* Deliberately not a route: it exists only because a check just produced it,
* and a `/set-password` URL that could be opened cold would be a way to set
* any account's password from nothing.
*/
const [setupUserid, setSetupUserid] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
/*
Each step brings its own panel into view.
The three steps are one screen, and on a phone the brand panel sits ABOVE the
form — so the card is taller than the viewport and answering the email step
replaces the panel below the fold. Without this the page does not move: you
press Continue, something changes off screen, and the password field you are
meant to type into is never shown to you.
Smooth rather than a jump, because unlike a route change this is a move
WITHIN a screen the reader is already looking at, and seeing the page travel
is what connects the button they pressed to the field that arrived. It is
skipped on the first render — arriving at a login already scrolled to the
form would hide the brand and the "which account is this" context above it —
and a reader who has asked for reduced motion gets the instant jump, via the
global rule in `index.css`.
DECLARED ABOVE THE `user` REDIRECT BELOW, and that placement is not cosmetic:
hooks must run in the same order on every render, and this component returns
early the moment a session exists. Put below that line, these two refs and
the effect would simply stop being called on the render that signs somebody
in — which is React's "rendered fewer hooks than expected" crash, on the
happy path.
*/
const panelRef = useRef<HTMLDivElement>(null);
const isFirstStep = useRef(true);
useEffect(() => {
if (isFirstStep.current) {
isFirstStep.current = false;
return;
}
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [step]);
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
@@ -75,14 +98,24 @@ export function LoginPage() {
async function handleEmail(event: FormEvent) {
event.preventDefault();
setError(null);
/* Cleared on every attempt, not only on the one that sets it. The notice
names the address that was typed — leaving it up while a second, valid
address is being checked tells the reader the wrong thing about it. */
setNotice(null);
setIsBusy(true);
try {
const check = await checkAccount(email);
if (check.state === 'setup') {
setSetupUserid(check.userid);
setNewPassword('');
setConfirmPassword('');
setStep('setup');
// An account that exists and has never been used. It is NOT offered a
// password form any more: this branch is reached by anybody who types
// an email, so a form here meant that knowing a merchant's address —
// usually printed on their shopfront — was enough to claim their
// account. The way in is the invitation, and this says so.
setNotice(
'This account has not been set up yet. Use the link in your invitation email, ' +
'or ask whoever set you up to send another.',
);
setPassword('');
} else {
setStep('password');
}
@@ -96,6 +129,7 @@ export function LoginPage() {
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setNotice(null);
setIsBusy(true);
try {
const session = await signIn(email, password);
@@ -105,10 +139,24 @@ export function LoginPage() {
// check and the submit an administrator could have cleared the password,
// and the account would otherwise dead-end on "Invalid Email".
if (cause instanceof PasswordSetupRequiredError) {
setSetupUserid(cause.userid);
setNewPassword('');
setConfirmPassword('');
setStep('setup');
// Between the probe and the submit, an administrator could have cleared
// the password. Same answer as the probe gives: the invitation is the
// only way to set one, so there is nothing to offer here.
setNotice(
'This account has not been set up yet. Use the link in your invitation email, ' +
'or ask whoever set you up to send another.',
);
setPassword('');
setStep('email');
} else if (cause instanceof WrongConsoleError) {
// Not a failure. The password was right and the account is fine — it
// belongs to the other console. Shown as guidance rather than as an
// error, because somebody told "sign-in failed" in red goes and resets a
// password that works. The field is cleared and the step returns to the
// email, since retyping the same password here will do the same thing.
setNotice(cause.message);
setPassword('');
setStep('email');
} else {
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
}
@@ -117,49 +165,20 @@ export function LoginPage() {
}
}
/** Back to the email field, from either of the two second steps. */
/** Back to the email field, from the password step. */
function restart() {
setStep('email');
setSetupUserid(null);
setPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setNotice(null);
}
/**
* Set the password, then sign in with it.
*
* Signing in afterwards rather than sending the person back to the form: they
* have just typed the password twice, and `applogin` is the only proof the
* write actually took.
*/
async function handleSetup(event: FormEvent) {
event.preventDefault();
if (setupUserid === null) return;
setError(null);
if (newPassword !== confirmPassword) {
setError('Those two passwords do not match.');
return;
}
setIsBusy(true);
try {
await setInitialPassword(setupUserid, newPassword);
const session = await signIn(email, newPassword);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Could not set the password');
} finally {
setIsBusy(false);
}
}
/* `handleSetup` stood here, with its own password form. Setting a first
password moved to `SetPasswordPage`, reached only from a signed invitation
— this screen is public, so a form here meant knowing a merchant's email
was enough to claim their account. */
const canSubmit =
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
const canSetup =
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
return (
<div
@@ -168,24 +187,38 @@ export function LoginPage() {
>
<div
className="login-split"
/* The sign-in card is the console's card, at the console's corner.
It was the largest radius in the product at 28px, carrying a
two-layer drop shadow, and it was the FIRST surface anybody saw —
so it set an expectation the rest of the app then did not meet. It
is now the same flat hairline card as every other surface, just
bigger. */
style={{
width: '100%',
maxWidth: 1024,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
borderRadius: 28,
boxShadow: '0 24px 56px -12px rgb(15 23 42 / .10), 0 8px 20px -8px rgb(15 23 42 / .05)',
background: 'var(--card-bg)',
border: 'var(--card-border)',
borderRadius: 'var(--card-radius)',
boxShadow: 'var(--card-shadow)',
overflow: 'hidden',
}}
>
<BrandPanel />
{step !== 'setup' ? (
<FormPanel
{/* The ref lives on a wrapper rather than inside the two panels, so
neither has to know it is being scrolled to. `display: grid` keeps
it out of the way: as a grid item the wrapper stretches to the row,
and the panel inside stretches with it, which is what centres the
form vertically beside the brand. A plain block here would collapse
to its content's height and un-centre it. */}
<div ref={panelRef} style={{ display: 'grid', minWidth: 0 }}>
<FormPanel
step={step}
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
error={error}
notice={notice}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
@@ -193,23 +226,8 @@ export function LoginPage() {
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={step === 'email' ? handleEmail : handleSubmit}
onBack={restart}
/>
) : (
<SetupPanel
email={email}
newPassword={newPassword}
confirmPassword={confirmPassword}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSetup}
onNewPassword={setNewPassword}
onConfirmPassword={setConfirmPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSetup}
onBack={restart}
/>
)}
/>
</div>
</div>
</div>
);
@@ -352,6 +370,8 @@ interface FormPanelProps {
password: string;
isPasswordVisible: boolean;
error: string | null;
/** The wrong-console sentence. Rendered beside `error`, never as one. */
notice: string | null;
isBusy: boolean;
canSubmit: boolean;
onEmail: (value: string) => void;
@@ -367,6 +387,7 @@ function FormPanel({
password,
isPasswordVisible,
error,
notice,
isBusy,
canSubmit,
onEmail,
@@ -459,6 +480,7 @@ function FormPanel({
something the system does not do. */}
<ErrorNote message={error} />
<ConsoleNote message={notice} />
<SubmitButton
canSubmit={canSubmit}
@@ -492,174 +514,11 @@ function FormPanel({
);
}
/* ────────────────────────────────────────────────────────────────────────────
Right — first sign-in, setting the password
──────────────────────────────────────────────────────────────────────────── */
interface SetupPanelProps {
email: string;
newPassword: string;
confirmPassword: string;
isPasswordVisible: boolean;
error: string | null;
isBusy: boolean;
canSubmit: boolean;
onNewPassword: (value: string) => void;
onConfirmPassword: (value: string) => void;
onToggleVisible: () => void;
onSubmit: (event: FormEvent) => void;
onBack: () => void;
}
/**
* The second state of this page, not a second page.
*
* An account created by `createtenantuser` or `createtenantlocation` is spawned
* with an empty password, so its owner's first sign-in cannot succeed and there
* is no reset email to fall back on. Before this existed the page detected the
* condition and then told the person to go and find an administrator — for an
* account that was working as designed.
*/
function SetupPanel({
email,
newPassword,
confirmPassword,
isPasswordVisible,
error,
isBusy,
canSubmit,
onNewPassword,
onConfirmPassword,
onToggleVisible,
onSubmit,
onBack,
}: SetupPanelProps) {
const isTooShort = newPassword !== '' && newPassword.length < MIN_PASSWORD_LENGTH;
const isMismatched = confirmPassword !== '' && newPassword !== confirmPassword;
return (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
marginBottom: 12,
padding: '4px 10px',
borderRadius: 999,
background: 'var(--color-surface-subtle)',
border: '1px solid var(--color-line)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-brand)',
}}
>
<ShieldCheck size={13} />
First sign-in
</div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Choose a password
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{email} has no password yet. Set one now and we will sign you straight in.
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field
label="New password"
htmlFor="new-password"
icon={<Lock size={15} />}
action={
<button
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
}
>
<input
id="new-password"
type={isPasswordVisible ? 'text' : 'password'}
value={newPassword}
onChange={(event) => onNewPassword(event.target.value)}
placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`}
autoComplete="new-password"
autoFocus
required
aria-invalid={isTooShort}
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
<Field label="Confirm password" htmlFor="confirm-password" icon={<Lock size={15} />}>
<input
id="confirm-password"
type={isPasswordVisible ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => onConfirmPassword(event.target.value)}
placeholder="Type it again"
autoComplete="new-password"
required
aria-invalid={isMismatched}
style={{
...inputStyle,
borderColor: isMismatched ? 'rgba(214,69,69,.45)' : 'var(--color-line)',
}}
/>
</Field>
<Hint>
{isTooShort
? `A few more characters — ${MIN_PASSWORD_LENGTH} is the minimum.`
: isMismatched
? 'Those two do not match yet.'
: 'Passwords on this backend are stored as typed. Do not reuse one from elsewhere.'}
</Hint>
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button type="button" onClick={onBack} style={backLinkStyle}>
Use a different account
</button>
</div>
</div>
);
}
/** A quiet line under the fields — advisory, never an error. */
function Hint({ children }: { children: ReactNode }) {
return (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
{children}
</p>
);
}
/* `SetupPanelProps`, `SetupPanel` and the `Hint` line they used stood here — a
second panel with its own password fields. Setting a first password moved to
`SetPasswordPage`, reached only from a signed invitation email. This screen
is public, so a password form on it meant that knowing a merchant's address
was enough to claim their account. */
/* ────────────────────────────────────────────────────────────────────────────
Shared form furniture
@@ -689,6 +548,41 @@ function ErrorNote({ message }: { message: string | null }) {
);
}
/**
* The right account at the wrong console.
*
* Deliberately not `ErrorNote`. Nothing failed: the password was correct and
* the account is in good standing — it simply belongs to the other site. Shown
* in red beside "Sign-in failed", it sends people to reset a password that
* works, or to ask an administrator to fix an account that is not broken.
*
* `role="status"` rather than `role="alert"` for the same reason: a screen
* reader should read this as information, not as something that went wrong.
*/
function ConsoleNote({ message }: { message: string | null }) {
if (!message) return null;
return (
<div
role="status"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 9,
padding: '11px 13px',
borderRadius: 12,
background: 'var(--color-surface-sunken, #F4F5F7)',
border: '1px solid var(--color-line, #E6E8EB)',
color: 'var(--color-ink-2, #52606D)',
fontSize: 13,
lineHeight: 1.55,
}}
>
<Building2 size={16} style={{ flex: 'none', marginTop: 1 }} />
{message}
</div>
);
}
function SubmitButton({
canSubmit,
isBusy,

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

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

View File

@@ -1,17 +1,22 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Switch } from '@astryxdesign/core/Switch';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { HStack } from '@astryxdesign/core/HStack';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Skeleton } from '@astryxdesign/core/Skeleton';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { APP_BROWSE_CATEGORY } from './tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
import { useSelection } from '@/components/useSelection';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
@@ -23,6 +28,7 @@ import {
} from '@/queries/hooks';
import { CatalogueCard } from './CatalogueCard';
import { CatalogueSidebar } from './CatalogueSidebar';
import { BulkImportDrawer } from './BulkImportDrawer';
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
const PAGE_SIZE = 24;
@@ -32,8 +38,6 @@ export interface CatalogueBrowserProps {
tenantid: number | undefined;
/** The outlet the import is written against. Required by the backend. */
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. */
actionLabel: string;
/**
@@ -54,15 +58,6 @@ export interface CatalogueBrowserProps {
* rather than one at a time into somebody else's shop.
*/
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. */
blockedReason?: string;
}
@@ -87,22 +82,43 @@ export interface CatalogueBrowserProps {
export function CatalogueBrowser({
tenantid,
locationid,
categoryOptions,
actionLabel,
onImport,
scope,
blockedReason,
isReadOnly,
alignWithTabs,
}: CatalogueBrowserProps) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [importInto, setImportInto] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
/*
Whether products added from this screen show their health score.
Two of the three ways to add a product never open the drawer — a card's Add
is one click and a bulk add is one click for forty — so without this they
had no say at all, and "showing" was decided for them.
One control for the whole screen rather than a question per product: a
prompt on every card add is the thing that was taken out, and a toggle asked
once for forty products is a question nobody answers honestly. The drawer's
own toggle still wins for the product it is open on, because somebody
reading that score has better information than this default does.
*/
const [showScoreOnAdd, setShowScoreOnAdd] = useState(true);
/*
The batch waiting on its per-product choices.
A selection is not one decision: three products can be two whose rating
should show and one whose should not, and a single switch for the whole set
forces that into a lie either way. Unlike the single-product path, a batch
has no drawer of its own — so this is the first place the question can be
asked at all rather than a second panel repeating one.
*/
const [batch, setBatch] = useState<CatalogueProduct[] | null>(null);
const [open, setOpen] = useState<CatalogueProduct | null>(null);
const [category, setCategory] = useState('');
const [page, setPage] = useState(1);
@@ -173,11 +189,77 @@ export function CatalogueBrowser({
const knownTotal = !category && !debounced ? brandTotal : undefined;
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);
return set;
}, [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({
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
onSuccess: async () => {
@@ -188,31 +270,147 @@ export function CatalogueBrowser({
},
});
/**
* 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 {
return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY));
} catch {
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,
catalogueid: product.id,
/* ALWAYS 2, and this is not a placeholder.
`getproductsbysubcategory` filters on `categoryid = 2` — the value the
app sends — so a per-product categoryid does not label the product, it
deletes it from the app's view. What the shopper actually reads as the
aisle heading is the subcategory below, which until now was 0 on every
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,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing.
retailprice: 0,
productcost: 0,
taxpercent: 0,
/* Omitted when the product has no score, so the import says nothing about
it rather than sending a choice nobody was offered. */
...(showHealthScore === undefined ? {} : { showhealthscore: showHealthScore }),
};
}
/*
Add straight from the card, in one click, as it has always been.
No question asked here, deliberately. The health score toggle lives in the
product's own drawer, where the score and its confidence are on screen — and
a card has neither, so a toggle on this path would be asking somebody to
judge a rating they cannot see.
The product imports with its score showing, which is the default and what
every product did before the toggle existed. Anyone who wants it off opens
the product and turns it off, which is the same control in the same place as
changing their mind later.
*/
async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return;
await confirmImport(product, showScoreOnAdd);
}
/*
The import, once the shopkeeper has answered.
One path for a single card and for a batch, so the two cannot write
different rows — the single-product button taking a different route from the
bulk action is how a selection quietly imports under a different category.
*/
/*
The import, once the shopkeeper has answered.
`showHealthScore` is undefined for a product with no score — the import then
says nothing about it rather than sending a choice nobody was offered, and
the backend reads an absent field as yes.
*/
async function confirmImport(product: CatalogueProduct, showHealthScore: boolean | undefined) {
if (!tenantid || !locationid) return;
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised — the backend leaves this
// optional for exactly that reason. An unclassified product is visibly
// unfinished; a wrongly classified one looks done and is only found by
// somebody browsing the wrong aisle.
categoryid: Number(importInto) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
await importOne.mutateAsync(
importRowFor(
product,
aisleIdForCategory(categoryNameFor(product), await aisleIds()),
showHealthScore,
),
);
setJustImported((set) => new Set(set).add(key));
setOpen(null);
} finally {
setBusy(null);
}
@@ -221,17 +419,6 @@ export function CatalogueBrowser({
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
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() {
setBrand('');
@@ -251,51 +438,55 @@ export function CatalogueBrowser({
<VStack gap={2}>
{scope}
{/* Pulled up onto the tab row's line and right-aligned, so search sits
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.
{/* Search, filters and the rail toggle, on their own line.
The pull-up is a CSS offset rather than the controls being rendered
inside the tab row, because the tab row belongs to the page
(`InventoryPage`) and these controls belong to the browser's own
state. Rendering them there would mean lifting `keyword` and the rail
toggle into two different pages to keep one row tidy. It reverts to
its own line below 900px, where the tabs need the width.
They used to be PULLED UP onto the page's tab row by a -42px offset
(`alignWithTabs`, `.catalogue-controls`), because that row looked
mostly empty to the right. It is not empty any more — Inventory's tab
row carries "Upload sheet" at its right end — and the offset does not
move a narrow control into a gap, it lays a FULL-WIDTH row on top of
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
narrows the whole catalogue, and the rail only lists brands. */}
<HStack
gap={1}
align="center"
wrap="wrap"
justify="end"
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
>
{/* Right-hand corner. The filter toggle and the search belong at the end
of the row, matching where search sits on Sales, Reports and Products,
rather than above the brand rail where they read as the rail's own
controls instead of the whole catalogue's. */}
<HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<SearchInput
label="Search the catalogue"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
width="full"
/>
</div>
</div>
{activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))}
{activeFilters.length > 1 ? (
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
) : null}
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
width={220}
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
startIcon={<Search size={14} />}
hasClear
/>
</HStack>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
@@ -305,7 +496,11 @@ export function CatalogueBrowser({
isLoading={brands.isLoading}
brand={brand}
category={category}
categories={(categories.data ?? []).filter(Boolean)}
/* The platform catalogue filters on the category name itself, so
value and label are the same string here. */
categories={(categories.data ?? [])
.filter(Boolean)
.map((name) => ({ value: name, label: name }))}
isLoadingCategories={categories.isLoading}
onBrand={setBrand}
onCategory={setCategory}
@@ -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">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
return (
<CatalogueCard
key={key}
product={product}
isImported={importedKeys.has(key)}
isBusy={busy === key}
isImported={isImported}
isBusy={busy === key || importMany.isPending}
isDisabled={!canImport}
actionLabel={actionLabel}
onOpen={() => setOpen(product)}
{...(canImport && !onImport && !isImported
? {
isSelected: selection.has(product.id),
onSelect: () => selection.toggle(product.id),
}
: {})}
{...(isReadOnly ? {} : { onImport: () => run(product) })}
/>
);
@@ -393,25 +669,51 @@ export function CatalogueBrowser({
</VStack>
</div>
{batch && batch.length > 0 ? (
<BulkImportDrawer
products={batch}
actionLabel={actionLabel}
isBusy={importMany.isPending}
defaultShowScore={showScoreOnAdd}
onCancel={() => setBatch(null)}
onConfirm={(choices) => {
const products = batch;
setBatch(null);
importMany.mutate({ products, choices });
}}
/>
) : null}
{open ? (
<CatalogueDetailDrawer
product={open}
isImported={importedKeys.has(catalogueKey(open))}
isBusy={busy === catalogueKey(open)}
actionLabel={actionLabel}
{...(showCategoryPicker
? { categoryOptions, categoryid: importInto, onCategoryChange: setImportInto }
: {})}
{...(canImport && !isReadOnly
? {
onImport: () => {
onImport: (showHealthScore: boolean | undefined) => {
const product = open;
if (onImport) setOpen(null);
run(product);
// A caller that owns the import — the shelving flow — takes
// over here and the drawer closes. Otherwise this writes the
// row itself, carrying the shopkeeper's answer.
if (onImport) {
setOpen(null);
onImport(product);
return;
}
void confirmImport(product, showHealthScore);
},
}
: {})}
{...(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)}
/>
) : null}

View File

@@ -1,5 +1,5 @@
import { useState, type MouseEvent } from 'react';
import { Check, ChevronLeft, ChevronRight, Eye, ImageOff, Plus } from 'lucide-react';
import { Check, ChevronLeft, ChevronRight, ImageOff, Plus } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
/**
@@ -29,6 +29,8 @@ export function CatalogueCard({
actionLabel,
onOpen,
onImport,
isSelected,
onSelect,
}: {
product: CatalogueProduct;
isImported: boolean;
@@ -37,6 +39,13 @@ export function CatalogueCard({
actionLabel: string;
onOpen: () => 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 [index, setIndex] = useState(0);
@@ -89,6 +98,22 @@ export function CatalogueCard({
controls.
*/}
<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 ? (
<img
// 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. */}
{isImported ? <span className="pcard-owned">In your list</span> : null}
{/* The action rail. One icon, because one is all we have a use for —
the reference's wishlist and compare have nothing behind them. */}
<span className="pcard-rail">
<span className="pcard-railbtn" aria-hidden="true">
<Eye size={14} />
</span>
</span>
{/* The photo switcher. Only when there is more than one to switch to —
an arrow that does nothing is worse than no arrow, and most of a

View File

@@ -1,17 +1,23 @@
import { useEffect, useState } from 'react';
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 { Selector } from '@astryxdesign/core/Selector';
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 { Check, DownloadCloud, ImageOff } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
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.
@@ -37,12 +43,15 @@ export interface CatalogueDetailDrawerProps {
isImported: boolean;
isBusy?: boolean;
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. */
onImport?: () => void;
/**
* Import this product.
*
* `showHealthScore` is the shopkeeper's answer to the toggle below the score,
* and `undefined` when this product has none — the import then says nothing
* about it rather than sending a choice nobody was offered.
*/
onImport?: (showHealthScore: boolean | undefined) => void;
/** Shown in place of the action when importing is unavailable. */
blockedReason?: string;
onClose: () => void;
@@ -53,14 +62,40 @@ export function CatalogueDetailDrawer({
isImported,
isBusy,
actionLabel = 'Add to my products',
categoryOptions,
categoryid = '',
onCategoryChange,
onImport,
blockedReason,
onClose,
}: 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 ?? [];
/*
Whether this shop will show the product's health score, and whether there is
one to show.
`hasScore` is reported up by the panel rather than looked up again here: the
panel already makes that call, already caches it, and already knows the
three ways a product can have no score — not food, not known to the service,
known but unscored. Asking a second time would be a second request and a
second opinion.
*/
const [showScore, setShowScore] = useState(true);
const [hasScore, setHasScore] = useState(false);
const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
const [failed, setFailed] = useState(false);
@@ -88,29 +123,35 @@ export function CatalogueDetailDrawer({
<Drawer
title={product.product_name}
subtitle={`${brand} · catalogue id ${product.id}`}
titleAside={isImported ? <Token label="In your list" size="sm" color="green" /> : undefined}
width={540}
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 —
at card size the ingredients and the net weight are not legible. */}
<Card padding={0} elevation="none">
<Section>
<button
type="button"
className="drawer-figure"
data-tall="true"
aria-label="Open photo full size"
onClick={() => setIsZoomed(true)}
disabled={!hero || failed}
style={{
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',
}}
style={{ width: '100%', cursor: hero && !failed ? 'zoom-in' : 'default' }}
>
{hero && !failed ? (
<img
@@ -118,165 +159,119 @@ export function CatalogueDetailDrawer({
alt={product.product_name}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
mixBlendMode: 'multiply',
}}
style={{ mixBlendMode: 'multiply' }}
/>
) : (
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
)}
</button>
</Card>
{images.length > 1 ? (
<VStack gap={0.5}>
<HStack gap={0.5} align="center">
<span style={{ color: 'var(--color-ink-4)', display: 'flex' }}>
<Info size={12} />
</span>
<Text type="body" size="xsm" color="secondary">
{images.length > 1 ? (
<>
<div className="drawer-thumbs">
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail
// and mis-track the rest as you page through them.
key={`${index}-${src}`}
className="drawer-thumb"
data-active={index === heroAt}
aria-label={`Show photo ${index + 1}`}
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
>
<img src={src} alt="" referrerPolicy="no-referrer" />
</button>
))}
</div>
<span className="drawer-metric-note">
{images.length} photos — only the first is imported
</Text>
</HStack>
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail and
// mis-track the rest as you page through them.
key={`${index}-${src}`}
aria-label={`Show photo ${index + 1}`}
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
src={src}
alt=""
referrerPolicy="no-referrer"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</button>
))}
</div>
</VStack>
) : null}
</span>
</>
) : null}
</Section>
{/* 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. */}
<Card padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} padding={2}>
<VStack gap={0}>
<Text type="label" size="xsm" color="secondary">
MARKET PRICE RANGE
</Text>
<Text type="large" weight="semibold" hasTabularNumbers>
{product.price_range ?? '—'}
</Text>
</VStack>
{product.size ? <Token label={product.size} size="md" /> : null}
</HStack>
</Card>
{/* The kit's own metric, not a 22px figure typed here. It was the largest
type in any drawer in the console and it sat on a catalogue REFERENCE
— louder than the selling price in the product drawer next to it. */}
<Metrics cols={2}>
<Metric label="Market price range" value={product.price_range ?? '—'} />
<Metric label="Pack size" value={product.size || '—'} isSmall />
</Metrics>
{product.description ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
DESCRIPTION
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{product.description}
</Text>
</VStack>
<Section title="Description">
<p className="drawer-prose">{product.description}</p>
</Section>
) : 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 ? (
<div className="form-grid">
<div className="drawer-bullets">
{product.highlights?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
HIGHLIGHTS
</Text>
<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>
<div className="drawer-section">
<h4 className="drawer-section-title">Highlights</h4>
<Bullets items={product.highlights} />
</div>
) : null}
{product.nutrients?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
NUTRITION
</Text>
<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>
<div className="drawer-section">
<h4 className="drawer-section-title">Nutrition</h4>
<Bullets items={product.nutrients} />
</div>
) : null}
</div>
) : null}
{facts.length > 0 ? (
<>
<Divider />
<VStack gap={1}>
<Section title="Catalogue record">
<DrawerCard>
{facts.map((fact) => (
<HStack key={fact.label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{fact.label}
</Text>
<Text
type="body"
size="xsm"
style={{
textAlign: 'right',
...(fact.isMono ? { fontFamily: 'var(--font-mono)' } : {}),
}}
>
{fact.value}
</Text>
</HStack>
<Row
key={fact.label}
label={fact.label}
value={
fact.isMono ? (
<Mono>{fact.value}</Mono>
) : (
fact.value
)
}
/>
))}
</VStack>
</>
</DrawerCard>
</Section>
) : null}
{/* 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 ? (
<Banner
status="success"
@@ -287,33 +282,43 @@ export function CatalogueDetailDrawer({
) : blockedReason ? (
<Banner status="warning" title="Cannot import yet" description={blockedReason} />
) : onImport ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
{categoryOptions ? (
<Selector
label="Import into"
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}
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Adds this product with no price. It reaches no shop and cannot be sold until you
price and publish it.
</Text>
<Button
label={isBusy ? 'Adding…' : actionLabel}
variant="primary"
icon={<DownloadCloud size={14} />}
width="full"
isDisabled={Boolean(isBusy)}
onClick={onImport}
<Section title="Import into my products">
<DrawerCard>
<Row
label="Filed under"
value={<Badge label={suggested.category} colour="var(--color-brand)" />}
/>
</VStack>
</Card>
{/* The heading a shopper actually reads. The catalogue's 31
categories are finer than the app's ten aisles, so the two are
shown side by side rather than one standing in for the other —
and a product with no aisle is told so here, not discovered
missing from the app later. */}
<Row
label="Shown in the app under"
value={
aisle ? (
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
) : (
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
)
}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
<span 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}
{/* Gallery mode, driven by the same index the thumbnails set — so

View File

@@ -4,7 +4,7 @@ import { VStack } from '@astryxdesign/core/VStack';
import { ChevronDown } from 'lucide-react';
import type { CatalogueBrand } from '@/api/types';
const COLLAPSED_COUNT = 8;
const COLLAPSED_COUNT = 25;
/**
* The brand rail.
@@ -28,6 +28,7 @@ export function CatalogueSidebar({
category,
categories,
isLoadingCategories,
totalCount,
onBrand,
onCategory,
}: {
@@ -35,8 +36,26 @@ export function CatalogueSidebar({
isLoading: boolean;
brand: string;
category: string;
categories: string[];
/**
* Value and label kept apart.
*
* The platform catalogue filters on the category NAME, so the two are the same
* string there. A store's own products are grouped by aisle, where the filter
* is a `subcategoryid` and the name is what a reader recognises — passing one
* string for both would put "7" in the rail or filter on "Dairy".
*/
categories: { value: string; label: string }[];
isLoadingCategories: boolean;
/**
* What "All brands" counts, when it is not the sum of the rail.
*
* The platform catalogue's brands account for every row, so summing them is
* right there. A store's rows can have no brand at all: those are left out of
* the rail and still listed under "All brands", so the sum would be short by
* exactly the unbranded items and the top of the rail would contradict the
* list beside it.
*/
totalCount?: number;
onBrand: (brand: string) => void;
onCategory: (category: string) => void;
}) {
@@ -56,7 +75,9 @@ export function CatalogueSidebar({
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
All brands
<span className="rail-count">
{isLoading ? '' : brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0)}
{isLoading
? ''
: (totalCount ?? brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0))}
</span>
</button>
@@ -105,15 +126,15 @@ export function CatalogueSidebar({
>
All categories
</button>
{categories.map((name) => (
{categories.map((entry) => (
<button
key={name}
key={entry.value}
type="button"
className="rail-category"
data-active={category === name}
onClick={() => onCategory(category === name ? '' : name)}
data-active={category === entry.value}
onClick={() => onCategory(category === entry.value ? '' : entry.value)}
>
{name}
{entry.label}
</button>
))}
</>

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

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

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

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

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

View 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. */
}

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

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

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

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

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

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

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

View 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