Compare commits

...

53 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
203 changed files with 24181 additions and 7122 deletions

3
.gitignore vendored
View File

@@ -10,3 +10,6 @@ dist
# stayed for weeks.
*.zip
*.tgz
# Build output from scripts/mapPreview.mjs — a local viewer, never committed.
scripts/.preview/

View File

@@ -13,9 +13,30 @@ COPY package*.json ./
# never pinned, so the deployed bundle is built from dependencies nobody chose
# and nobody can reproduce.
#
# When this line fails, the fix is `npm install` locally and COMMIT the updated
# package-lock.json. The build should not paper over a lock file that is out of
# date; it should say so.
# When this line fails, the fix is to update package-lock.json locally and
# COMMIT it. The build should not paper over a lock file that is out of date;
# it should say so.
#
# ── But `npm install` alone is how the lock got broken once ─────────────────
#
# This image is node:22-alpine, which ships npm 10.9.8. A developer on npm 11
# running `npm install` rewrites the lock in a shape npm 10 rejects: npm 11
# prunes optional platform packages that npm 10 still validates. Adding leaflet
# on npm 11.6.2 dropped `@emnapi/core` and `@emnapi/runtime` — transitive
# optional deps of `@tailwindcss/oxide-wasm32-wasi` — and the next deploy died
# here with "Missing: @emnapi/core@1.11.3 from lock file". Nothing was wrong
# with the code; the lock was genuinely incomplete and this line was right to
# refuse it.
#
# So after changing dependencies, prove the lock against THIS npm before
# pushing, in a scratch directory so node_modules is not disturbed:
#
# mkdir /tmp/lockcheck && cp package.json package-lock.json /tmp/lockcheck/
# cd /tmp/lockcheck && npx npm@10.9.8 ci
#
# and if it fails, regenerate with the same version:
#
# npx npm@10.9.8 install --package-lock-only
RUN npm ci --no-audit --no-fund
COPY . .
@@ -33,6 +54,33 @@ COPY . .
ARG VITE_API_BASE="https://fiesta.nearle.app"
ENV VITE_API_BASE=$VITE_API_BASE
# Which console this image is.
#
# platform → platform.nearledaily.com, Nearle's own staff
# merchant → app.nearledaily.com, merchants and their branch users
#
# The same source builds both. What the flag changes is which workspace's routes
# are mounted and which roles may sign in — neither image lets the other's
# accounts through. Both images still CONTAIN both workspaces' compiled chunks;
# the unmounted one is never fetched because nothing routes to it.
#
# Defaulting to `merchant` keeps every existing deployment behaving exactly as
# it did. The platform build is the one that has to be asked for — the opposite
# default would turn every environment that had not been told about this into a
# platform console on the day it shipped.
# There is no workspace flag in this image.
#
# One existed while a single codebase served both consoles and had to be told
# which it was. Nearle's own staff now have their own application —
# `nearle-platform`, its own repository and its own deploy — and this image is
# the merchant console and nothing else.
#
# The platform console's address, for the sentence shown to a staff member who
# signs in at the wrong site. A build argument rather than a constant because
# the two are separate deployments and either can move.
ARG VITE_PLATFORM_HOST="platform.nearledaily.com"
ENV VITE_PLATFORM_HOST=$VITE_PLATFORM_HOST
RUN npm run build
# Stage 2 — serve

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

603
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@astryxdesign/core": "^0.4.5",
"@stylexjs/stylex": "^0.19.0",
"@tanstack/react-query": "^5.101.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -21,16 +22,72 @@
"devDependencies": {
"@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3",
"@types/jsdom": "^30.0.0",
"@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.20.3",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.1.10",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@astryxdesign/cli": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@astryxdesign/cli/-/cli-0.4.5.tgz",
@@ -691,6 +748,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz",
"integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz",
"integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1133,6 +1343,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz",
@@ -1997,6 +2225,43 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsdom": {
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz",
"integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/tough-cookie": "*",
"parse5": "^8.0.0",
"undici-types": "^8.9.0"
}
},
"node_modules/@types/jsdom/node_modules/undici-types": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.2.tgz",
"integrity": "sha512-7/+aSjzkUoLc92hV22bTW4aGanXf800zbwguhcICs0OAoCF9wDOE4wkopQ+SqfhXZm8mCK8gHpdTs7pZUWzK3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.22",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
@@ -2027,6 +2292,13 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -2429,6 +2701,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -2592,6 +2874,20 @@
"integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==",
"license": "BSD"
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2720,6 +3016,35 @@
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2738,6 +3063,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -2775,6 +3107,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-toolkit": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz",
@@ -2977,6 +3322,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
@@ -3038,6 +3396,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
@@ -3105,6 +3470,57 @@
}
}
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3141,6 +3557,12 @@
"node": ">=0.10.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -3481,6 +3903,13 @@
"semver": "bin/semver"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3563,6 +3992,19 @@
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
@@ -3655,6 +4097,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -3816,6 +4268,16 @@
"redux": "^5.0.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
@@ -3863,6 +4325,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -3960,6 +4435,13 @@
"integrity": "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==",
"license": "MIT"
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -4004,6 +4486,26 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.12"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -4014,6 +4516,32 @@
"node": ">=14.14"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4075,6 +4603,16 @@
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -4483,6 +5021,54 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
@@ -4536,6 +5122,23 @@
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -8,7 +8,7 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3100",
"typecheck": "tsc --noEmit",
"test": "tsx --test \"src/**/*.test.ts\"",
"test": "tsx --import ./tools/stub-css.mjs --test \"src/**/*.test.ts\" \"src/**/*.test.tsx\"",
"contract": "node scripts/contract.mjs",
"db": "node scripts/db.mjs",
"appgap": "node scripts/appgap.mjs",
@@ -16,12 +16,14 @@
"check:optimiser": "tsx scripts/checkOptimiser.ts",
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"",
"appsweep": "node scripts/appsweep.mjs",
"appfix": "node scripts/appfix.mjs"
"appfix": "node scripts/appfix.mjs",
"preview:map": "node scripts/mapPreview.mjs"
},
"dependencies": {
"@astryxdesign/core": "^0.4.5",
"@stylexjs/stylex": "^0.19.0",
"@tanstack/react-query": "^5.101.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -32,10 +34,13 @@
"devDependencies": {
"@astryxdesign/cli": "^0.4.5",
"@tailwindcss/vite": "^4.3.3",
"@types/jsdom": "^30.0.0",
"@types/leaflet": "^1.9.22",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.20.3",
"typescript": "^7.0.2",

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

View File

@@ -5,7 +5,7 @@ import { RequireRole, useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { withStaleChunkRecovery } from '@/lib/staleChunk';
import { LoginPage } from '@/features/auth/LoginPage';
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
import { SetPasswordPage } from '@/features/auth/SetPasswordPage';
import { StoreAdminShell } from '@/features/store-admin/StoreAdminShell';
import { StoreUserShell } from '@/features/store-user/StoreUserShell';
@@ -28,12 +28,8 @@ import { StoreUserShell } from '@/features/store-user/StoreUserShell';
const named = <T extends string>(key: T, loader: () => Promise<Record<T, ComponentType>>) =>
lazy(withStaleChunkRecovery(() => loader().then((module) => ({ default: module[key] }))));
const StoresPage = named('StoresPage', () => import('@/features/nearle-admin/pages/StoresPage'));
const StoreDetailPage = named('StoreDetailPage', () => import('@/features/nearle-admin/pages/StoreDetailPage'));
const OnboardTenantPage = named('OnboardTenantPage', () => import('@/features/nearle-admin/pages/OnboardTenantPage'));
const GlobalCataloguePage = named('GlobalCataloguePage', () => import('@/features/nearle-admin/pages/GlobalCataloguePage'));
const PartnersPage = named('PartnersPage', () => import('@/features/nearle-admin/pages/PartnersPage'));
const NearleUploadsPage = named('UploadsPage', () => import('@/features/nearle-admin/pages/UploadsPage'));
/* Lazy like the rest, and it matters more here: this page pulls in leaflet and
its stylesheet, which nobody who never opens the fleet map should download. */
/* One Console for both workspaces — it reads its own scope from BranchScope,
which pins a store user to their outlet and lets an admin choose. Both routes
@@ -84,38 +80,25 @@ export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* Reached only from an invitation email, and public by necessity — the
account has no password yet, so there is no way to obtain a session
first. The token in the query string is the whole credential. */}
<Route path="/set-password" element={<SetPasswordPage />} />
{/* Nearle Admin — the platform workspace */}
<Route
path="/nearle"
element={
<RequireRole role="nearle-admin">
<Suspense fallback={<RouteFallback />}>
<NearleAdminShell />
</Suspense>
</RequireRole>
}
>
<Route index element={<Navigate to="/nearle/stores" replace />} />
<Route path="stores" element={<StoresPage />} />
<Route path="stores/:tenantId" element={<StoreDetailPage />} />
<Route path="onboard/tenant" element={<OnboardTenantPage />} />
{/* Branch onboarding moved to the Store Admin workspace — the merchant
opens their own outlets. Kept as a redirect rather than deleted so a
bookmark or a stale link lands on the directory instead of a 404. */}
<Route path="onboard/branch" element={<Navigate to="/nearle/stores" replace />} />
<Route path="catalogue" element={<GlobalCataloguePage />} />
{/* Delivery partners — the companies that supply riders. Platform-side
only: a merchant is assigned one, never allowed to create one. */}
<Route path="partners" element={<PartnersPage />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path can
never bounce out to a HOME_ROUTE that points back into this
workspace and loop. */}
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
</Route>
{/* Nearle staff have their own application.
The platform workspace lived here until the two consoles were split.
It is now `nearle-platform`, a separate repository deployed on its own
domain, and this one serves merchants and their branch users only.
`/nearle/*` is therefore not a protected path here — it is not a path
at all, and falls to the catch-all like any other unknown URL. A
`nearle-admin` account cannot sign in either: `login` and `restore`
refuse it before a session is written. */}
{/* Store Admin — the merchant workspace, scoped to one tenant's branches */}
<Route
path="/admin"
element={
@@ -179,6 +162,10 @@ export function App() {
<Route path="*" element={<Navigate to="/store/console" replace />} />
</Route>
{/* `user` is only ever a merchant role — `login` and `restore` refuse a
Nearle staff account before a session is written — so its home route
is always one of the two workspaces above, and this cannot bounce
somewhere that does not exist. */}
<Route
path="*"
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}

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

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

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

View File

@@ -310,6 +310,23 @@ export interface RiderShift {
shifthours?: number;
}
/**
* A new working window.
*
* Times go as `HH:MM`; the server normalises `9:00` and `09:00:00` to the same
* thing, because the dropdown labels a shift by concatenating the two columns
* and the rows inserted by hand over the years use every spelling.
*/
export interface NewRiderShift {
applocationid: number;
shiftname?: string;
starttime: string;
endtime: string;
basefare?: number;
additionalcharges?: number;
fuelcharge?: number;
}
export const ridersApi = {
/**
* The directory — everyone, working today or not.
@@ -350,6 +367,21 @@ export const ridersApi = {
shifts: (applocationid: number) =>
api.list<RiderShift>(`${WEB}/partners/getridershifts`, { applocationid }),
/**
* Open a shift window in a region.
*
* A rider cannot be hired without a shift, and this table could only be read
* until now — so a region that shipped with no shift rows was a region no
* rider could ever be added to, from anywhere in the product. The drawer
* showed "No shifts set up for this region" and that was the end of it.
*
* `shifthours` is deliberately not sent. The server works it out from the two
* times, because it feeds rider pay and is the one field a person gets wrong
* with nothing downstream to catch it.
*/
createShift: (shift: NewRiderShift) =>
api.post<RiderShift>(`${WEB}/partners/createridershift`, shift),
/** Delivery partners a rider can ride for. */
partners: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
@@ -388,4 +420,71 @@ export const partnersApi = {
/** The regions one partner covers. */
locations: (partnerid: number) =>
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
/**
* Every GPS ping a partner's riders sent over a window.
*
* ── Scope, and why this is a platform endpoint ────────────────────────────
*
* It filters on `partnerid` or on the rider's `applocationid` — never on a
* tenant. A partner's riders serve every merchant that partner supplies, so
* there is no tenant this could be scoped to, and asking by region would hand
* one merchant every rider in the city. That is why the fleet view lives in
* the platform console and not in a shop's.
*
* ── What comes back, and what it is not ───────────────────────────────────
*
* One row per ping: rider, timestamp, latitude, longitude. Dense — 320,132
* rows across eight riders for August 2026 — so a month-wide window is a
* large response and callers ask for a day or two at a time.
*
* The coordinates are NOT a trail. Every row for a given rider carries the
* same pair: one rider's 2,404 pings on 14 August 2026 all read 11.052998,
* 76.929958, and the same holds on every day and region checked. The app
* stamps a location once and repeats it on each heartbeat, so distance, speed
* and "time moving" cannot be derived from this and anything of that shape
* would be invented. Rider positions that actually move are written on the
* `deliveries` rows; see `deliveryTrack`.
*
* The row also carries `login`, `logout`, `workhours`, `shorthours` and
* `breakhours`, and every one of them is empty or zero on every row measured.
* Nothing closes a shift. So the timestamps are what this endpoint is good
* for — who was online and for how long — and shifts are inferred from the
* gaps between pings; see `riderShifts`.
*/
riderLogs: (query: { partnerid?: number; applocationid?: number; fromdate: string; todate: string }) =>
api.list<RiderPingRow>(`${WEB}/partners/getriderlogs`, {
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.applocationid ? { applocationid: query.applocationid } : {}),
fromdate: query.fromdate,
todate: query.todate,
}),
};
/**
* One row of `getriderlogs`.
*
* The shift columns are typed because they are sent, and documented as empty
* because they are: nothing on the platform writes them. Reading `workhours`
* and believing it is the mistake this comment exists to prevent.
*/
export interface RiderPingRow {
logid?: number;
logdate: string;
userid: number;
username?: string;
partnerid?: number;
latitude?: string;
longitude?: string;
shiftid?: number;
shifthours?: number;
/** Always empty on production data. See `riderLogs`. */
login?: string;
/** Always empty on production data. See `riderLogs`. */
logout?: string;
/** Always 0 on production data. See `riderLogs`. */
workhours?: number;
shorthours?: number;
breakhours?: number;
logstatus?: number;
}

101
src/api/deliverySlots.ts Normal file
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;
}

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,
@@ -116,6 +164,32 @@ export const insightsApi = {
...range,
}),
/**
* What is actually IN an order.
*
* The only read that carries line items. Every list endpoint returns an
* order's totals and never its contents, which is why the detail sheet could
* say an order was worth ₹840 and not what the ₹840 bought.
*
* The envelope rather than `api.list`, because the authoritative total lives
* outside `details`: `OrderDetail.Orderamount` is `json:"-"` on the server, so
* `pricedetails.orderamount` is the only place it appears. Summing the lines
* would be recomputing a figure Fiesta has already worked out, and the two
* would disagree the first time a discount rounded differently.
*/
orderItems: async (orderheaderid: number) => {
const envelope = await api.envelope<OrderItem[]>(`${WEB}/orders/getorderdetails`, {
params: { orderheaderid },
});
return {
// `details: null` for an order with no lines is as common here as `[]`;
// see the note on `api.list`.
items: envelope.details ?? [],
amount: envelope.pricedetails?.orderamount ?? 0,
tax: envelope.pricedetails?.totaltaxamount ?? 0,
};
},
deliverySummary: (tenantid: number, range: DateRange = {}) =>
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),

View File

@@ -1,3 +1,4 @@
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
import type { OrderRow } from './types';
/**
@@ -22,18 +23,46 @@ import type { OrderRow } from './types';
* does, which is the only reason its two identically-named endpoints do not
* collide.
*
* ── What we deliberately do not call ────────────────────────────────────────
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
*
* `optimization/riderassign` works and is useless to us: it assigns against its
* OWN fleet. Sending our orders returned them assigned to `rider_id 883,
* "Rajan A"` — not one of ours, and no parameter changes that. Auto-assignment
* needs either a riders-inline variant of that endpoint or a mapping onto
* `routemate`'s `doormile/assign`, which does accept `milers` inline. Neither
* is wired here until somebody decides which.
* This file used to say the opposite — that `riderassign` was useless because
* it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours".
* That was wrong, and it was wrong for the ordinary reason: an unfamiliar id
* was taken for a stranger without checking the roster.
*
* Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and
* so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111,
* 1114, every one of them partner 44's, which is the Coimbatore fleet. The
* solver reads the same database Fiesta does: `getallriders` on jupiter and
* `getriders` on Fiesta return identical rosters and identical on-duty state.
*
* So auto-assignment works and `assign` below wires it up.
*
* ── What it cannot do yet, and why that is not our bug ──────────────────────
*
* The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0
* for all 118 riders on the platform — every region, checked the same day. So
* `active_riders_pool` is 0 and every order comes back unassigned with "No
* riders found (check partner online status)". Supplying riders in the body
* does not help: `riders` and `active_riders` were both tried against a rider
* the on-duty endpoint DOES report, and the pool stayed 0.
*
* Whatever is meant to set `onduty` is not setting it. That is worth asking the
* app team about; nothing here can work around it.
*
* ── `routemate` is gone ─────────────────────────────────────────────────────
*
* The old console's second mode posted to `routemate.workolik.com/api/v1/
* optimization/riderassign?strategy=multi_trip`, which accepted a rider list
* inline. It answers 404 now, with and without the query string, so that route
* around the `onduty` gate is closed too.
*/
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
/** A solve can legitimately take a while. Past this, something is wrong. */
const SOLVE_TIMEOUT_MS = 90_000;
/** An order as the optimiser hands it back — ours, plus the routing it added. */
export interface SequencedStop extends OrderRow {
/** 1..N. The order to visit in. */
@@ -103,6 +132,52 @@ async function post<T>(path: string, body: unknown): Promise<T> {
return (payload.details ?? (payload as unknown)) as T;
}
/**
* A run that is allowed to take its time, and to be cancelled.
*
* Separate from `post` for two reasons: the caller needs the whole envelope
* rather than `details`, and a solve is slow enough that abandoning it has to
* be possible. The caller's cancel and the timeout both have to be able to stop
* it, so they are combined rather than one winning.
*/
async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
const timer = new AbortController();
const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS);
const onAbort = () => timer.abort();
signal?.addEventListener('abort', onAbort);
try {
const response = await fetch(`${OPTIMISER_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: timer.signal,
});
if (!response.ok) {
// 422 is the solver rejecting the payload and saying which field. Worth
// showing verbatim — "422" on its own is not actionable.
const text = await response.text().catch(() => '');
throw new OptimiserError(
text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`,
);
}
return await response.json();
} catch (error) {
if (error instanceof OptimiserError) throw error;
if ((error as Error)?.name === 'AbortError') {
throw new OptimiserError(
signal?.aborted
? 'Cancelled.'
: 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.',
);
}
throw new OptimiserError('Could not reach the route optimiser');
} finally {
clearTimeout(stop);
signal?.removeEventListener('abort', onAbort);
}
}
export const optimiserApi = {
/**
* Put a set of orders in a sensible order.
@@ -132,4 +207,38 @@ export const optimiserApi = {
*/
reconcile: (riders: PlannedRider[]) =>
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }),
/**
* Propose a rider for each waiting order.
*
* A PLAN, not a commitment. Nothing is written anywhere until the operator
* accepts it and the console makes its own `createdeliveries` call to Fiesta
* through `buildDelivery` — the same path the manual assign bar uses, so
* there is exactly one way a delivery is ever written. Safe to run twice and
* safe to walk away from.
*
* ── Raw, not unwrapped ────────────────────────────────────────────────────
*
* `postRaw`, because the answer here IS the envelope: `zones` carries the
* assignment, `meta` carries the accounting and the per-order reasons, and
* `details` is only the flat fallback shape. `post` would hand back `details`
* alone and throw the plan away — and `details` is `[]` on every run that
* assigns nothing, which is every run today.
*
* ── Slow on purpose ───────────────────────────────────────────────────────
*
* Seven seconds for five orders, measured, and it is a solver so it grows
* with the problem. `signal` is taken so a caller can offer to cancel; the
* timeout is deliberately generous, since killing a run early abandons work
* the operator is waiting on and teaches them the button is broken.
*
* `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict —
* and the literal string `null` is a value it accepts, meaning "your default".
*/
assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) =>
postRaw(
`/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`,
request,
signal,
),
};

View File

@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
status?: string;
}
/**
* What happened to somebody's first-password invitation.
*
* Reported beside the person rather than folded into success or failure: they are
* hired either way, and an unsent invitation is a task — resend, or correct the
* address — not a hire to retry.
*/
export interface InviteOutcome {
sent: boolean;
/** Why not — an unconfigured mail host, a rejecting relay. Absent when it sent. */
reason?: string;
}
export interface CreateStaffResult {
person: StaffInfo;
invite: InviteOutcome;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
@@ -85,7 +103,42 @@ export const staffApi = {
unassign: (body: { tenantid: number; userid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
/**
* Hires somebody, and emails them their way in.
*
* The account is created with NO password — nothing on this path sets one —
* and since the sign-in screen stopped offering to set a first password, the
* emailed link is the only way in. So the outcome of that email comes back
* beside the person, and the screen shows it: an invitation that did not send
* means somebody has been added to the directory who cannot sign in, and
* nothing else in the product would ever mention it.
*/
create: (body: CreateStaffRequest): Promise<CreateStaffResult> =>
api.postEnvelope<StaffInfo>(`${WEB}/users/create`, body).then((envelope) => ({
person: (envelope.details ?? {}) as StaffInfo,
invite: {
// Absent means a backend that predates the invitation — read as "not
// sent", so a half-deployed pair cannot claim an email that never left.
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
})),
/**
* Sends somebody's first-password link again.
*
* By `userid`, not by tenant: the owner's invitation is reachable by tenantid
* because a business has one owner, but staff and branch logins are many, so
* an operator chasing one names the person.
*
* The backend refuses anybody who already has a password and says to send them
* to the sign-in page instead. That refusal is the point — an endpoint that
* re-issues a working password link for any account on request is a password
* reset, and nothing here verifies identity well enough to have one. It arrives
* as a `FiestaError` and is shown as written.
*/
resendInvite: (userid: number) =>
api.post<unknown>(`${WEB}/tenants/resendinvite`, { userid }),
/**
* Update a person.
@@ -140,11 +193,23 @@ export const posUsersApi = {
* non-array and hands back `[]`, so the page showed "no till accounts" for a
* shop that had them. Same shape trap as `/health/location`.
*/
list: (tenantid: number, locationid: number) =>
list: (tenantid: number, locationid: number, includeInactive = false) =>
api
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
tenantid,
locationid,
/*
Off by default, because that is what every existing caller assumed.
The listing excludes inactive accounts unless asked
(`posUserRepository.go:494` — `LOWER(COALESCE(a.status,'active')) <>
'inactive'`), and `WebListPosUsers` reads `include_inactive` from the
query string. Nothing sent it, which had two consequences: a supervisor
who switched a cashier off in the drawer watched them disappear from the
list with no trace and no way back, and any Status column could only
ever render "Active" because that was the only status a row could have.
*/
...(includeInactive ? { include_inactive: 'true' } : {}),
})
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
@@ -173,13 +238,46 @@ export const posUsersApi = {
*
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
*/
shifts: (tenantid: number, locationid: number) =>
/**
* The tenant's shift windows.
*
* `locationid` is optional and usually omitted. A shift belongs to the
* business, so the tenant's set is what every picker in the console should
* offer; naming a branch narrows to the tenant's plus that branch's own, for
* the outlet that genuinely runs different hours.
*/
shifts: (tenantid: number, locationid?: number) =>
api
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
tenantid,
locationid,
...(locationid ? { locationid } : {}),
})
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
/**
* Open a shift window at one branch.
*
* The endpoint has existed since till staff were built; nothing in the console
* called it. So `getstaffshifts` answered `{"shifts": []}` at every branch —
* the comment on StoreStaffPage says exactly that — and the picker on this
* drawer offered "Any shift" and nothing else, for everyone, permanently.
*
* `weekdays` is a seven-character mask starting Monday; empty means every day.
* The server rejects anything that is not seven 0/1 characters, so it is sent
* as the mask rather than as a list the console would have to encode twice.
*/
createShift: (
tenantid: number,
shift: { name: string; start_time: string; end_time: string; weekdays?: string },
/** Only for a shop that genuinely runs different hours from the business. */
locationid?: number,
) =>
api.post<StaffShift>(`${WEB}/tenants/createstaffshift`, {
tenantid,
// Zero means the whole tenant, which is the ordinary case.
locationid: locationid ?? 0,
...shift,
}),
};
/**

View File

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

135
src/api/routing.ts Normal file
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,
};

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

@@ -2,6 +2,7 @@
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { InviteOutcome } from './people';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
@@ -44,6 +45,31 @@ export interface CreateTenantRequest {
/** Everything the branch-onboarding form collects. */
export interface CreateBranchRequest {
tenantid: number;
/**
* The delivery region, inherited from the tenant's existing outlets.
*
* `tenantlocations.applocationid` has no column default, so a create that
* omits it stores 0 — and `resolveOfflineLocationContext` in
* `orderRepository.go` calls this column "authoritative", with no fallback
* anywhere for a zero. It is also copied straight onto the login the backend
* spawns for the branch, so the outlet AND the person running it both end up
* in no region at all.
*
* Measured 2026-09-15: 43 of 75 live branches carry 0. Regions are
* 1 = Coimbatore, 2 = Madurai, 23 = Nagercoil.
*/
applocationid?: number;
/**
* Also inherited, and also without a column default.
*
* `orderRepository.go` documents the consequence in its own comment —
* "tenantlocations carries 0 for moduleid/partnerid at outlets whose live
* orders nonetheless use non-zero values" — and works around it by copying
* the scaffolding off the most recent real order at that outlet. A branch
* commissioned five minutes ago has no such order, so the workaround has
* nothing to copy and the joins are left to resolve against a zero.
*/
moduleid?: number;
locationname: string;
email?: string;
contactno?: string;
@@ -76,6 +102,20 @@ export interface CreateBranchRequest {
operatorid?: number;
}
/**
* A commissioned branch, and what happened to its operator's invitation.
*
* The branch exists either way. A login that was not emailed is a task for
* whoever commissioned it — resend, or fix the address — and not a branch to
* create again.
*/
export interface CreateBranchResult {
branch: TenantLocation;
invite: InviteOutcome;
/** The login the branch spawned, for a resend. 0 when a person was placed. */
operatorUserid: number;
}
export interface TenantListQuery {
pageno?: number;
pagesize?: number;
@@ -151,9 +191,24 @@ export const tenantsApi = {
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*
* `invite` reports the spawned login's first-password email. It is `sent:
* false` with NO reason when an `operatorid` was named — nothing was created,
* so there was nothing to send, and that is not a failure to report.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
api
.postEnvelope<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body)
.then((envelope) => ({
branch: (envelope.details ?? {}) as TenantLocation,
invite: {
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
// The spawned login, so a failed invitation can be resent without
// hunting for the row by eye. 0 when an existing person was placed.
operatorUserid: envelope.inviteuserid ?? 0,
})),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),

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;
}
/* ────────────────────────────────────────────────────────────────────────────
@@ -118,6 +195,19 @@ export interface TenantInfo {
/** Capitalised on the wire. */
Accountname?: string;
status: string;
/**
* How many outlets this merchant has.
*
* Sent by `getalltenants` only, so it is optional — every other endpoint
* returning a `TenantInfo` leaves it out.
*
* The store list used to work this out for itself, by counting how many
* times a tenantid appeared, on the belief that the endpoint returned one
* row per tenant-location pair. It returns one row per tenant and always
* has, so the count was always 1 and the platform's "Branches" total was
* really its tenant total.
*/
branchcount?: number;
}
export interface TenantLocation {
@@ -207,10 +297,37 @@ export interface Product {
/** Capitalised on the wire. */
Subcategoryname?: string;
catalogueid?: number;
/**
* Whether this shop shows the product's health score.
*
* The merchant's decision, made at import and changeable from the product.
* Optional because a backend that predates the column sends nothing — and an
* absent value means YES, which is what every product showed before this
* existed.
*
* Gates the SCORE only. The nutrition figures are shown either way: they are
* what the label states, while the score is a judgement of them.
*/
showhealthscore?: boolean;
productname?: string;
productimage?: string;
/** A JSON-encoded array of URLs, held as a string. Parse before use. */
productimages?: string;
/**
* What the global catalogue said about this product when it was imported —
* a JSON-encoded object, held as a string. Parse with `catalogueFactsOf`.
*
* Carries the fields the product table has no columns for: the FSSAI
* licence, nutrition, highlights, providers, the typical retail range and
* the variant key. The import used to drop all of them and the drawer went
* back to the catalogue on every open, which stopped working the moment a
* re-scrape retired the source row — taking a licence number off a product
* the shop was still selling.
*
* Absent on anything that did not come from the catalogue. The drawer still
* falls back to the live lookup for those.
*/
cataloguefacts?: string;
productdesc?: string;
productsku?: string;
brandid?: number;
@@ -300,6 +417,17 @@ export interface ImportCatalogueProductRequest {
retailprice: number;
productcost: number;
taxpercent: number;
/**
* Whether this shop will show the product's health score.
*
* Omitted means yes. The backend reads an absent field and an explicit
* `false` as different answers on purpose, so a caller that does not know
* about this does not have its imports read as a deliberate no.
*
* Only the score. The nutrition figures are sent either way — they are what
* the label states, while the score is a judgement of them.
*/
showhealthscore?: boolean;
}
/** Body of POST /web/products/createproductlocation — send an ARRAY. Upserts. */
@@ -329,23 +457,53 @@ export interface ProductStockRequest {
Orders & deliveries — the summary shapes the console reads
──────────────────────────────────────────────────────────────────────────── */
export interface OrderSummary {
totalorders?: number;
delivered?: number;
/**
* What `getordersummary` and `getlocationsummary` actually send.
*
* ── These two used to declare fields the backend has never sent ─────────────
*
* Both carried `totalorders?: number` and `revenue?: number`. Neither exists in
* either response. Verified live on 2026-09-11 against tenant 1147 — the whole
* body is:
*
* {"total":168,"created":84,"pending":1,"processing":0,
* "delivered":1,"cancelled":82,"locationid":1185,"locationname":"R mart "}
*
* The count is `total`, not `totalorders`, and there is no money on it at all.
* Because both were optional AND the interface had an `[key: string]: unknown`
* index signature, `summary.revenue ?? 0` type-checked perfectly and evaluated
* to 0 forever. Four pages read those two names — Reports, both Consoles and
* the platform's store detail — so every "App revenue" and "Online orders"
* figure on this console read ₹0 / 0 against a shop with 84 real orders worth
* ₹25,728 in the same range.
*
* The index signature is gone with them. It is what let the names drift from
* the wire in the first place: with it, a typo and a renamed column are both
* legal, and neither shows up until someone looks at a screen and sees a zero.
*
* Revenue is not here because it is not available here. It comes from the order
* rows — see `branchOrderStats` in `features/store-admin/branchStats.ts`.
*/
interface OrderCounts {
/** Every order in scope, whatever its status. The other counts sum to this. */
total?: number;
created?: number;
pending?: number;
processing?: number;
delivered?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
}
export interface LocationOrderSummary {
export interface OrderSummary extends OrderCounts {
tenantid?: number;
tenantname?: string;
locationid?: number;
locationname?: string;
}
export interface LocationOrderSummary extends OrderCounts {
locationid?: number;
locationname?: string;
totalorders?: number;
delivered?: number;
cancelled?: number;
revenue?: number;
[key: string]: unknown;
}
export interface DeliverySummary {
@@ -445,8 +603,17 @@ export interface OrderRow {
*/
ordervalue?: number;
orderamount?: number;
/** Cash to collect on delivery. Shown only when > 0. */
collectionamt?: number;
/*
* `collectionamt` was declared here and read by three screens. It does not
* exist in Fiesta — `grep -rn "collectionamt" --include=*.go` returns nothing
* — so every one of them showed a dash or zero from the day it was written.
* Removed 2026-09-28 along with those screens.
*
* A type can describe a field the server has never sent, and nothing catches
* it: the optional marker makes `undefined` legal, and `?? 0` turns it into a
* plausible figure. That is the trap, and it is worth remembering before the
* next optional money field is added on the strength of a field name.
*/
deliverycharge?: number;
deliveryamt?: number;
paymenttype?: number;
@@ -518,6 +685,24 @@ export interface OrderRow {
* filters the whole list away — the drop address is the real test.
*/
deliverytype?: string;
/**
* The delivery window the customer chose, and the day it falls on.
*
* Absent on every order placed before windows shipped, and on every order
* from a branch that has set none — which is most of them. Treat absence as
* "no window was asked for", never as a problem with the order.
*
* Distinct from `deliverytime` above, which is a timestamp of what happened.
* These two say what was asked for.
*/
deliveryslotid?: number;
deliveryslotdate?: string;
/** Joined from `deliveryslots`, so a renamed window reads correctly on old orders. */
slotkey?: string;
deliveryslotname?: string;
deliveryslotstart?: string;
deliveryslotend?: string;
}
/**
@@ -732,6 +917,19 @@ export interface DeliveryRow {
deliverytime?: string;
canceltime?: string;
expecteddeliverytime?: string;
/**
* The delivery window the customer asked for.
*
* Joined from the ORDER — `deliveries` has no window of its own, on purpose,
* so there is one truth about what was asked for. Absent on most rows.
*/
deliveryslotid?: number;
deliveryslotdate?: string;
slotkey?: string;
deliveryslotname?: string;
deliveryslotstart?: string;
deliveryslotend?: string;
itemcount?: number;
orderamount?: number;
pickupcustomer?: string;
@@ -823,6 +1021,19 @@ export interface StaffInfo {
locationid?: number;
locationname?: string;
status?: string;
/**
* Whether they have ever chosen a password.
*
* Every person here is created with an empty one and emailed a link to set it.
* Until that link is used they are in this list, in every branch picker, and
* cannot sign in — and `status` does not say so, because an Active account with
* no password is refused at the login screen like any other.
*
* So `false` is the row that needs an action: a lost invitation, waiting to be
* sent again. Optional because a backend that predates the column sends
* nothing, and the directory then simply does not make the claim.
*/
issetup?: boolean;
}
/**

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

@@ -75,7 +75,7 @@ export class ErrorBoundary extends Component<Props, State> {
margin: '48px auto',
maxWidth: 620,
padding: '28px 32px',
borderRadius: 16,
borderRadius: 'var(--card-radius)',
border: '1px solid #F1D3D3',
background: '#FFFBFB',
fontFamily: 'inherit',
@@ -85,8 +85,19 @@ export class ErrorBoundary extends Component<Props, State> {
{isStale ? 'This page was updated while you were working' : `Something on ${area} failed to render`}
</h2>
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
{/*
In DEVELOPMENT nothing was deployed, and saying so sends a developer
looking for a release that never happened. The same failure has a
different cause here: the dev server restarted, or Vite re-optimised
its dependencies after a config change, and either invalidates the
module URLs this tab is holding. Same fix, different sentence —
`import.meta.env.DEV` is compiled out of the production bundle, so
this costs a deployed console nothing.
*/}
{isStale
? 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
? import.meta.env.DEV
? 'The dev server restarted or re-optimised its dependencies, so the module URL this tab was holding no longer resolves. Nothing is wrong with your code — reloading picks up the current module graph.'
: 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
: 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}
</p>
<pre
@@ -109,7 +120,10 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={isStale ? this.reload : this.reset}
style={{
padding: '9px 18px',
borderRadius: 999,
/* The same corner as every other control on the console. A stadium
here made the one button a person sees on a broken screen the one
button shaped unlike the rest of the product. */
borderRadius: 'var(--card-radius-sm, 10px)',
border: '1px solid #D9C0C0',
background: '#FFFFFF',
fontSize: 14,

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,4 +1,5 @@
import type { ReactNode } from 'react';
import { StickyRow } from './StickyRow';
export interface PageHeaderProps {
title: string;
@@ -39,9 +40,19 @@ export interface PageHeaderProps {
*
* The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again.
*
* ── Held under the nav bar ──────────────────────────────────────────────────
*
* This row does not scroll away. It used to: reaching the bottom of a long list
* put the tab you were in, the search and every action button off the top of
* the window, so the only way back to the controls over what you were reading
* was to scroll back through all of it. The page still scrolls normally —
* nothing here gets a scrollbar of its own — the row simply stays. The
* behaviour and the canvas it paints live in `StickyRow` and `.page-sticky`.
*/
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
const hasRow = Boolean(actions || (isTabsInline && tabs));
const hasTabsRow = Boolean(tabs && !isTabsInline);
return (
<>
@@ -64,29 +75,33 @@ export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderPro
below the nav bar while Inventory sat at 24. The gap now comes from one
place, the column's own padding, and every page matches.
*/}
{hasRow ? (
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
{/* Left of the row when inline, so the tabs start at the page's left
edge and the actions stay on the right. */}
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
{hasRow || hasTabsRow ? (
<StickyRow>
{hasRow ? (
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
{/* Left of the row when inline, so the tabs start at the page's
left edge and the actions stay on the right. */}
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
) : null}
</header>
) : null}
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
{hasTabsRow ? <div>{tabs}</div> : null}
</StickyRow>
) : null}
</>
);
}

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

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

@@ -105,6 +105,29 @@ export function AppShell({
const isMobile = useIsMobile();
const menuRef = useRef<HTMLDivElement>(null);
/*
A new page starts at the top.
A single-page app does not reload, so the document keeps whatever scroll
offset the last page was left at: read Sales down to row 40, click Inventory,
and Inventory opens 2,000px down — usually past everything it has, so it
looks empty. Nothing in the app was resetting this.
INSTANT, deliberately, and it is the one place in the console that opts out
of the smooth scrolling `index.css` turns on. `behavior: 'smooth'` here would
animate those 2,000px on every navigation, which means a second of the new
page flying past before it settles — the page arriving late, rather than the
page arriving. Smooth is for a move you asked for within a page; a route
change is not one.
Keyed on `pathname` alone, not on the whole location: the branch scope and
the date range travel in the query string, and re-scoping a table you are
halfway down should leave you where you were.
*/
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
// Escape closes the account menu. A menu that can only be dismissed by
// finding the trigger again is a trap for anyone on a keyboard, and this one
// sits over the page rather than beside it.

View File

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

View File

@@ -28,39 +28,85 @@ import {
export interface DateScopeValue {
preset: RangePreset;
/**
* What the PAGES read — always a real window.
*
* When nobody has picked anything this is the default week, not an empty
* object. Left empty, every read on the console became unbounded and each
* part of a page bounded it differently: the Console's chart drew however
* many days the last 500 orders happened to span (nine, in practice) while
* the KPI tiles beside it totalled all of them, and the counter figures came
* from a POS summary with no window at all. One page, three answers.
*/
range: DateRange;
/**
* What the PICKER shows — empty until somebody chooses.
*
* This is the half that keeps the filter unselected on arrival. The two are
* separate on purpose: "no filter has been set" is a fact about the control,
* and "which week are we looking at" is a fact about the data, and collapsing
* them into one value is what forced a choice between a filter nobody set and
* a page with no window.
*/
chosen: DateRange;
set: (preset: RangePreset, range: DateRange) => void;
/** Back to no filter at all — what an empty state offers as a way out. */
clear: () => void;
/** True when a range is actually narrowing the page. */
/** True when the USER has narrowed the page, not merely that a window exists. */
isFiltered: boolean;
}
/** Where the console starts. Month to date is what most pages want. */
export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
/**
* The window the console shows when nobody has picked one.
*
* A WEEK — while the picker still shows nothing selected. Those are two
* separate statements and both are deliberate.
*
* The filter opens unselected because the console used to narrow every page
* before anyone asked: somebody signing in to see how trade is going was shown
* a slice with no sign that it was one, and the range then followed them across
* the whole console.
*
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
* capped only by `pagesize`, and each part of a page then bounded itself
* differently — the Console's chart drew however many days the last 500 orders
* happened to span (nine), the KPI tiles beside it totalled all of those
* orders, and the counter figures came from a POS summary with no window
* whatsoever. One page, three different answers to "when".
*
* A default window is not a filter. Nothing is hidden from the reader, the
* control is empty, and one choice replaces it.
*/
const DEFAULT_WINDOW: RangePreset = 'week';
const DateScopeContext = createContext<DateScopeValue | null>(null);
export function DateScopeProvider({ children }: { children: ReactNode }) {
const [preset, setPreset] = useState<RangePreset>(DEFAULT_RANGE_PRESET);
const [range, setRange] = useState<DateRange>(() => presetRange(DEFAULT_RANGE_PRESET));
/* What the user picked. Empty until they do — this is what the picker shows. */
const [preset, setPreset] = useState<RangePreset>('custom');
const [chosen, setChosen] = useState<DateRange>({});
const value = useMemo<DateScopeValue>(
() => ({
const value = useMemo<DateScopeValue>(() => {
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
return {
preset,
range,
chosen,
/* The window the pages actually read: what was picked, or the default
week. Resolved here, once, so every read on a page shares it — the
orders, the POS summaries and the chart cannot end up describing
different spans. */
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
set: (nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
setChosen(nextRange);
},
clear: () => {
setPreset('custom');
setRange({});
setChosen({});
},
isFiltered: Boolean(range.fromdate || range.todate),
}),
[preset, range],
);
isFiltered: hasChoice,
};
}, [preset, chosen]);
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
}
@@ -81,5 +127,5 @@ export function useDateScope(): DateScopeValue {
/** The control itself. Rendered once, in the top bar. */
export function DateScopePicker() {
const dates = useDateScope();
return <DateRangePicker preset={dates.preset} range={dates.range} onChange={dates.set} />;
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
}

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

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

@@ -43,10 +43,29 @@ export interface Paged<T> {
to: number;
}
export const DEFAULT_PAGE_SIZE = 25;
/**
* Fifteen rows, everywhere, until somebody says otherwise.
*
* Every table on the console takes this — no call site overrides it — so this
* constant IS the console's answer to "how long is a page", and changing it
* here changes all of them at once. That is the point: a default that drifts per
* screen makes the pager something to re-read on every page rather than
* something learnt once.
*
* It is a default, not a limit. The selector in `TablePager` offers the sizes
* below and `setPageSize` keeps whatever is chosen for as long as the table is
* mounted, so a person working through a long list can widen it and stay widened.
*/
export const DEFAULT_PAGE_SIZE = 15;
/** The choices offered in the page-size selector. */
export const PAGE_SIZES = [10, 25, 50, 100];
/**
* The choices offered in the page-size selector.
*
* 15 is here because the default has to be selectable — a selector whose current
* value is not one of its options has nothing to show as chosen. 10 stays: it is
* the "show me less" end, and the default moving up is no reason to take it away.
*/
export const PAGE_SIZES = [10, 15, 25, 50, 100];
export function usePaged<T>(
rows: readonly T[],

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,16 +1,17 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Switch } from '@astryxdesign/core/Switch';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { HStack } from '@astryxdesign/core/HStack';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Skeleton } from '@astryxdesign/core/Skeleton';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { APP_BROWSE_CATEGORY } from './tenantCategories';
@@ -27,6 +28,7 @@ import {
} from '@/queries/hooks';
import { CatalogueCard } from './CatalogueCard';
import { CatalogueSidebar } from './CatalogueSidebar';
import { BulkImportDrawer } from './BulkImportDrawer';
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
const PAGE_SIZE = 24;
@@ -93,6 +95,30 @@ export function CatalogueBrowser({
const [debounced, setDebounced] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
/*
Whether products added from this screen show their health score.
Two of the three ways to add a product never open the drawer — a card's Add
is one click and a bulk add is one click for forty — so without this they
had no say at all, and "showing" was decided for them.
One control for the whole screen rather than a question per product: a
prompt on every card add is the thing that was taken out, and a toggle asked
once for forty products is a question nobody answers honestly. The drawer's
own toggle still wins for the product it is open on, because somebody
reading that score has better information than this default does.
*/
const [showScoreOnAdd, setShowScoreOnAdd] = useState(true);
/*
The batch waiting on its per-product choices.
A selection is not one decision: three products can be two whose rating
should show and one whose should not, and a single switch for the whole set
forces that into a lie either way. Unlike the single-product path, a batch
has no drawer of its own — so this is the first place the question can be
asked at all rather than a second panel repeating one.
*/
const [batch, setBatch] = useState<CatalogueProduct[] | null>(null);
const [open, setOpen] = useState<CatalogueProduct | null>(null);
const [category, setCategory] = useState('');
const [page, setPage] = useState(1);
@@ -196,15 +222,28 @@ export function CatalogueBrowser({
one-argument builder and stayed valid the moment the second argument
arrived.
*/
mutationFn: async (products: CatalogueProduct[]) => {
mutationFn: async ({
products,
choices,
}: {
products: CatalogueProduct[];
/* Keyed by `catalogueKey`. A product missing from the map takes the
screen's setting — the import request carries one value per row, so a
mixed batch is one call, not one per choice. */
choices: Map<string, boolean>;
}) => {
const ids = await aisleIds();
return productsApi.importFromCatalogue(
products.map((product) =>
importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids)),
importRowFor(
product,
aisleIdForCategory(categoryNameFor(product), ids),
choices.get(catalogueKey(product)) ?? showScoreOnAdd,
),
),
);
},
onSuccess: async (_result, products) => {
onSuccess: async (_result, { products }) => {
// Ticked locally as well as refetched: the imported list is a separate
// query and the grid would otherwise show them as un-imported until it
// came back, tempting a second click.
@@ -296,6 +335,7 @@ export function CatalogueBrowser({
function importRowFor(
product: CatalogueProduct,
subcategoryid: number,
showHealthScore: boolean | undefined,
): ImportCatalogueProductRequest {
return {
tenantid: tenantid as number,
@@ -318,19 +358,59 @@ export function CatalogueBrowser({
retailprice: 0,
productcost: 0,
taxpercent: 0,
/* Omitted when the product has no score, so the import says nothing about
it rather than sending a choice nobody was offered. */
...(showHealthScore === undefined ? {} : { showhealthscore: showHealthScore }),
};
}
/*
Add straight from the card, in one click, as it has always been.
No question asked here, deliberately. The health score toggle lives in the
product's own drawer, where the score and its confidence are on screen — and
a card has neither, so a toggle on this path would be asking somebody to
judge a rating they cannot see.
The product imports with its score showing, which is the default and what
every product did before the toggle existed. Anyone who wants it off opens
the product and turns it off, which is the same control in the same place as
changing their mind later.
*/
async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return;
await confirmImport(product, showScoreOnAdd);
}
/*
The import, once the shopkeeper has answered.
One path for a single card and for a batch, so the two cannot write
different rows — the single-product button taking a different route from the
bulk action is how a selection quietly imports under a different category.
*/
/*
The import, once the shopkeeper has answered.
`showHealthScore` is undefined for a product with no score — the import then
says nothing about it rather than sending a choice nobody was offered, and
the backend reads an absent field as yes.
*/
async function confirmImport(product: CatalogueProduct, showHealthScore: boolean | undefined) {
if (!tenantid || !locationid) return;
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync(
importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())),
importRowFor(
product,
aisleIdForCategory(categoryNameFor(product), await aisleIds()),
showHealthScore,
),
);
setJustImported((set) => new Set(set).add(key));
setOpen(null);
} finally {
setBusy(null);
}
@@ -377,31 +457,36 @@ export function CatalogueBrowser({
It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */}
<HStack gap={1} align="center" wrap="wrap" justify="end">
{/* Right-hand corner. The filter toggle and the search belong at the end
of the row, matching where search sits on Sales, Reports and Products,
rather than above the brand rail where they read as the rail's own
controls instead of the whole catalogue's. */}
<HStack gap={1} align="center" wrap="wrap" justify="end" width="100%">
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<SearchInput
label="Search the catalogue"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
width="full"
/>
</div>
</div>
{activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))}
{activeFilters.length > 1 ? (
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
) : null}
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
width={220}
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
startIcon={<Search size={14} />}
hasClear
/>
</HStack>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
@@ -411,7 +496,11 @@ export function CatalogueBrowser({
isLoading={brands.isLoading}
brand={brand}
category={category}
categories={(categories.data ?? []).filter(Boolean)}
/* The platform catalogue filters on the category name itself, so
value and label are the same string here. */
categories={(categories.data ?? [])
.filter(Boolean)
.map((name) => ({ value: name, label: name }))}
isLoadingCategories={categories.isLoading}
onBrand={setBrand}
onCategory={setCategory}
@@ -489,6 +578,20 @@ export function CatalogueBrowser({
</Text>
</label>
{/* The decision for everything added from this screen.
Beside the add controls rather than in a settings panel,
because it is read at the moment it applies — somebody
about to add forty products can see what those forty will
do. Ticked by default, which is what every product did
before the toggle existed. */}
<Switch
label="Show health score on products I add"
value={showScoreOnAdd}
onChange={setShowScoreOnAdd}
size="sm"
/>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button
@@ -508,10 +611,13 @@ export function CatalogueBrowser({
size="sm"
isLoading={importMany.isPending}
isDisabled={importMany.isPending}
/* The whole batch takes the screen's setting — the
checkbox beside this button. Asking per product
across a selection of forty is a question nobody
answers honestly, and any of them can still be
changed afterwards from the product. */
onClick={() =>
importMany.mutate(
rows.filter((product) => selection.has(product.id)),
)
setBatch(rows.filter((product) => selection.has(product.id)))
}
/>
</HStack>
@@ -563,6 +669,21 @@ export function CatalogueBrowser({
</VStack>
</div>
{batch && batch.length > 0 ? (
<BulkImportDrawer
products={batch}
actionLabel={actionLabel}
isBusy={importMany.isPending}
defaultShowScore={showScoreOnAdd}
onCancel={() => setBatch(null)}
onConfirm={(choices) => {
const products = batch;
setBatch(null);
importMany.mutate({ products, choices });
}}
/>
) : null}
{open ? (
<CatalogueDetailDrawer
product={open}
@@ -571,10 +692,17 @@ export function CatalogueBrowser({
actionLabel={actionLabel}
{...(canImport && !isReadOnly
? {
onImport: () => {
onImport: (showHealthScore: boolean | undefined) => {
const product = open;
if (onImport) setOpen(null);
run(product);
// A caller that owns the import — the shelving flow — takes
// over here and the drawer closes. Otherwise this writes the
// row itself, carrying the shopkeeper's answer.
if (onImport) {
setOpen(null);
onImport(product);
return;
}
void confirmImport(product, showHealthScore);
},
}
: {})}

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';
/**
@@ -145,13 +145,6 @@ export function CatalogueCard({
what the packaging itself carries in larger type than we could. */}
{isImported ? <span className="pcard-owned">In your list</span> : null}
{/* The action rail. One icon, because one is all we have a use for —
the reference's wishlist and compare have nothing behind them. */}
<span className="pcard-rail">
<span className="pcard-railbtn" aria-hidden="true">
<Eye size={14} />
</span>
</span>
{/* The photo switcher. Only when there is more than one to switch to —
an arrow that does nothing is worse than no arrow, and most of a

View File

@@ -9,6 +9,9 @@ import {
Bullets,
DrawerButton,
DrawerCard,
Metric,
Metrics,
Mono,
Row,
Section,
} from '@/features/store-admin/drawerKit';
@@ -41,7 +44,14 @@ export interface CatalogueDetailDrawerProps {
isBusy?: boolean;
actionLabel?: string;
/** Absent when the caller has nowhere to import to yet. */
onImport?: () => void;
/**
* Import this product.
*
* `showHealthScore` is the shopkeeper's answer to the toggle below the score,
* and `undefined` when this product has none — the import then says nothing
* about it rather than sending a choice nobody was offered.
*/
onImport?: (showHealthScore: boolean | undefined) => void;
/** Shown in place of the action when importing is unavailable. */
blockedReason?: string;
onClose: () => void;
@@ -73,6 +83,19 @@ export function CatalogueDetailDrawer({
const aisle = aisleForCategory(suggested.category);
const images = product.images ?? [];
/*
Whether this shop will show the product's health score, and whether there is
one to show.
`hasScore` is reported up by the panel rather than looked up again here: the
panel already makes that call, already caches it, and already knows the
three ways a product can have no score — not food, not known to the service,
known but unscored. Asking a second time would be a second request and a
second opinion.
*/
const [showScore, setShowScore] = useState(true);
const [hasScore, setHasScore] = useState(false);
const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
const [failed, setFailed] = useState(false);
@@ -102,7 +125,7 @@ export function CatalogueDetailDrawer({
subtitle={`${brand} · catalogue id ${product.id}`}
width={540}
onClose={onClose}
{...(isImported ? { meta: <Badge label="In your list" colour="#0f8a5f" /> } : {})}
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
{...(!isImported && !blockedReason && onImport
? {
isFooterFilled: true,
@@ -112,7 +135,7 @@ export function CatalogueDetailDrawer({
variant="primary"
icon={<DownloadCloud size={15} />}
isDisabled={Boolean(isBusy)}
onClick={onImport}
onClick={() => onImport(hasScore ? showScore : undefined)}
/>
),
}
@@ -162,7 +185,7 @@ export function CatalogueDetailDrawer({
</button>
))}
</div>
<span style={{ font: '400 12.5px/1.4 var(--font-sans)', color: 'var(--color-ink-4)' }}>
<span className="drawer-metric-note">
{images.length} photos — only the first is imported
</span>
</>
@@ -171,31 +194,13 @@ export function CatalogueDetailDrawer({
{/* A RANGE, not a price. What the shop charges is set after the import,
and conflating the two is how a catalogue figure ends up on a shelf. */}
<DrawerCard tone="brand">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
}}
>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span className="drawer-metric-label">Market price range</span>
<span
style={{
font: '600 22px/1.2 var(--font-sans)',
color: 'var(--color-ink-1)',
fontVariantNumeric: 'tabular-nums',
}}
>
{product.price_range ?? '—'}
</span>
</span>
{product.size ? <Badge label={product.size} colour="var(--color-brand)" /> : null}
</div>
</DrawerCard>
{/* The kit's own metric, not a 22px figure typed here. It was the largest
type in any drawer in the console and it sat on a catalogue REFERENCE
— louder than the selling price in the product drawer next to it. */}
<Metrics cols={2}>
<Metric label="Market price range" value={product.price_range ?? '—'} />
<Metric label="Pack size" value={product.size || '—'} isSmall />
</Metrics>
{product.description ? (
<Section title="Description">
@@ -212,9 +217,19 @@ export function CatalogueDetailDrawer({
`brand` and `image_id` come straight off the catalogue row, so no
lookup is needed to find the key. */}
{/* The decision and the thing being decided about, in one place.
"Show health score?" against a product name is unanswerable; the same
question directly beneath "22/100 — Less healthy, matched at 47%
confidence" answers itself. That is the whole reason the toggle lives
here rather than in a confirmation of its own. */}
<HealthScorePanel
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
category={product.category}
onScoreResolved={setHasScore}
{...(hasScore && !isImported && onImport
? { showToggle: { isShown: showScore, onChange: setShowScore } }
: {})}
/>
{product.highlights?.length || product.nutrients?.length ? (
@@ -243,7 +258,7 @@ export function CatalogueDetailDrawer({
label={fact.label}
value={
fact.isMono ? (
<span style={{ fontFamily: 'var(--font-mono)' }}>{fact.value}</span>
<Mono>{fact.value}</Mono>
) : (
fact.value
)
@@ -282,21 +297,21 @@ export function CatalogueDetailDrawer({
label="Shown in the app under"
value={
aisle ? (
<Badge label={aisle} colour="#0f8a5f" />
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
) : (
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
)
}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
<span className="drawer-caption">
{suggested.rule === 'catalogue'
? `The catalogue's own category for this product.`
: product.category
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
</span>
<span style={{ font: '400 13px/1.55 var(--font-sans)', color: 'var(--color-ink-3)' }}>
<span className="drawer-caption">
{aisle
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}

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

@@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { useDateScope } from '@/components/shell/DateScope';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { summariseBranch } from '@/features/store-admin/posStatus';
import { branchOrderStats, NO_ORDERS } from '@/features/store-admin/branchStats';
import {
useLocationProducts,
useLocationSummary,
useOrders,
usePosHealthByBranch,
usePosSalesByBranch,
useStockRequests,
@@ -52,7 +53,15 @@ export function ConsolePage() {
const dates = useDateScope();
const branchIds = useMemo(() => scoped.map((branch) => branch.locationid), [scoped]);
const orders = useLocationSummary(tenantid || undefined);
/* The same rows `useConsoleSeries` reads, so this shares its cache entry
rather than adding a request. `getlocationsummary`, which used to feed this
table, carries no money and ignores the date picker — see `branchStats.ts`. */
const orders = useOrders(
tenantid
? { tenantid, ...(selected ? { locationid: selected } : {}), ...dates.range, pagesize: 500 }
: undefined,
);
const byBranch = useMemo(() => branchOrderStats(orders.data ?? []), [orders.data]);
const posNow = usePosSalesByBranch(branchIds, dates.range);
const posHealth = usePosHealthByBranch(branchIds);
const products = useLocationProducts(tenantid || undefined, selected ?? undefined, 0, {
@@ -75,26 +84,26 @@ export function ConsolePage() {
const rows = useMemo<BranchRow[]>(
() =>
scoped.map((branch, index) => {
const order = (orders.data ?? []).find((entry) => entry.locationid === branch.locationid);
const order = byBranch.get(branch.locationid) ?? NO_ORDERS;
const pos = posNow[index]?.data;
return {
branch,
// `getlocationsummary` carries no date filter, so it is used for the
// per-branch split the table needs. The dated period figures come
// from the order rows — see `totals` below.
onlineRevenue: order?.revenue ?? 0,
onlineOrders: order?.totalorders ?? 0,
cancelled: order?.cancelled ?? 0,
delivered: order?.delivered ?? 0,
counterRevenue: pos?.grosssales ?? 0,
counterBills: pos?.billcount ?? 0,
onlineRevenue: order.revenue,
onlineOrders: order.orders,
cancelled: order.cancelled,
delivered: order.delivered,
// Both routes to a counter sale — a till that synced, and a till
// that was offline whose day was imported as OFFLINE-tagged orders.
// See the note in the Store Admin console, which had the same gap.
counterRevenue: (pos?.grosssales ?? 0) + order.counterRevenue,
counterBills: (pos?.billcount ?? 0) + order.counterOrders,
health: summariseBranch(posHealth[index]?.data ?? [], now),
pendingRequests: (requests.data ?? []).filter(
(entry) => entry.locationid === branch.locationid,
).length,
};
}),
[scoped, orders.data, posNow, posHealth, requests.data, now],
[scoped, byBranch, posNow, posHealth, requests.data, now],
);
const totals = useMemo(() => {

View File

@@ -63,40 +63,84 @@
.kpi-strip { grid-auto-columns: minmax(0, 1fr); overflow-x: visible; }
}
/* Icon beside the figure, nothing beneath it. The card used to carry a trend
row and a sparkline; with those gone it is a label and a number, and the
layout should not still be shaped around what it no longer holds. */
.kpi {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 16px;
display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center;
}
.kpi-icon {
width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center;
background: var(--color-brand-tint); color: var(--color-brand);
}
.kpi-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.kpi-note { font: 400 11.5px/1.35 var(--font-sans); color: var(--color-ink-4); }
/* Two lines rather than an ellipsis. With Nearle Buddy open each card is about
195px wide, and "Cancelled Orders" truncated to "Cance..." is a label that
has stopped doing its job. The min-height keeps all five value rows level
whether the label took one line or two. */
.kpi-label { font: 500 12.5px/1.25 var(--font-sans); color: var(--color-ink-3); }
.kpi-value { font: 600 22px/1.15 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
/* The Console's own KPI tile used to live here — `.kpi`, `.kpi-icon`,
`.kpi-label`, `.kpi-value`, `.kpi-note` — a second implementation of the
same object that `components/KpiCard.tsx` renders, with a different icon
radius, a different value size and a different gap. Both appeared on the
same screen. The component won, this strip now renders it, and the classes
are gone rather than left as a near-copy for someone to edit by mistake. */
/* ── Panels ─────────────────────────────────────────────────────────────── */
/* ── Panels ─────────────────────────────────────────────────────────────────
The Console's panels are the SAME object as `components/Panel.tsx` — a
framed card with a header band, a body and a footer band. They stay as CSS
classes rather than becoming that component because these six sections each
lay their own body out (a chart, a donut, a map, a list of alerts) and have
no table or pager to hand it; the component's job is the table case.
What matters is that they are the same DESIGN. They were not: this file had
a white header with a 15.5px title and a sub-line, the component has a
tinted band with an 11px eyebrow, and the two sat on the same screen. The
band and the type below are now the component's, restated here.
The panel no longer pads itself — the bands and the body pad separately, or
a header band cannot reach the panel's edges. */
.panel {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface); padding: 18px;
display: flex; flex-direction: column; gap: 14px; min-width: 0;
border: var(--card-border); border-radius: var(--card-radius);
background: var(--card-bg); box-shadow: var(--card-shadow);
display: flex; flex-direction: column; min-width: 0;
/* The clip belongs on the element that owns the radius, or the header
band's tint squares off the top two corners. */
overflow: hidden;
}
/* The body. Every direct child of `.panel` that is not a band is content, so
the padding and the stack live on a wrapper the sections already render. */
.panel > :not(.panel-head):not(.panel-foot) {
padding: 16px 18px;
}
/* Consecutive content blocks should not double their gutter. */
.panel > :not(.panel-head):not(.panel-foot) ~ :not(.panel-head):not(.panel-foot) {
padding-top: 0;
}
.panel-head {
display: flex; justify-content: space-between; align-items: center;
gap: 14px; flex-wrap: wrap;
min-height: 48px; padding: 8px 18px;
background: var(--color-surface-subtle);
border-bottom: var(--card-border);
}
/* An eyebrow, matching the table panels: 11px caps. A 15.5px heading inside a
card competes with the page's own title above it. */
.panel-title {
margin: 0;
font: 600 11px/1.2 var(--font-sans);
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--color-ink-3);
}
/* Title and sub-line sit on ONE row, the way the table panel's title and count
do. Every section wraps the pair in a plain `div`, which stacks them by
default — and a stacked pair is the two-line header this band replaced. */
.panel-head > div:first-child {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
min-width: 0;
}
.panel-sub {
margin: 0;
font: 400 12px/1.4 var(--font-sans);
color: var(--color-ink-4);
}
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
.panel-title { margin: 0; font: 600 15.5px/1.25 var(--font-sans); color: var(--color-ink-1); }
.panel-sub { margin: 3px 0 0; font: 400 12.5px/1.4 var(--font-sans); color: var(--color-ink-3); }
.panel-foot {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3);
padding: 8px 18px;
background: var(--color-surface-subtle);
border-top: var(--card-border);
font: 400 12px/1.5 var(--font-sans); color: var(--color-ink-3);
}
.panel-empty {
display: flex; flex-direction: column; gap: 5px; padding: 28px 20px; text-align: center;
@@ -140,76 +184,282 @@
.link-quiet { font: 500 12.5px/1 var(--font-sans); color: var(--color-brand); text-decoration: none; }
.link-quiet:hover { text-decoration: underline; }
/* ── Sales overview ─────────────────────────────────────────────────────── */
.sales-split { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr); }
@media (min-width: 720px) { .sales-split { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
/* ── Sales overview ─────────────────────────────────────────────────────────
.chart-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 10px; }
.chart-axis {
display: flex; flex-direction: column; justify-content: space-between; height: 190px;
text-align: right; font: 400 10.5px/1 var(--font-sans); color: var(--color-ink-4);
The chart's two colours are here, and they are measured rather than chosen.
The pair before them was the brand purple and the brand at 32% on white, and
the palette checks failed three ways: #662582 sits at OKLCH L 0.406, below
the 0.43 floor for a chart mark; the 32% mix came out at chroma 0.047, which
reads as grey rather than as a colour; and it cleared 1.82:1 against white,
under the 3:1 minimum. The second channel was a mark you could not reliably
see.
These two clear every check on white with no warnings — lightness band,
chroma floor, CVD separation (ΔE 27.4 worst adjacent under protanopia),
normal-vision floor (29.4) and contrast. Purple + BLUE was the obvious
alternative and it fails: ΔE 5.8 under deuteranopia, well under the 8 target.
--chart-online is a lighter step of the Nearle purple rather than the brand
itself, because the brand is out of the band for a mark. It is used as this
series and nowhere else.
Dark mode is not reachable today (main.tsx pins mode="light"), so only the
light pair is validated. A dark theme needs its own steps and its own run of
the checks, not an automatic flip. */
:root {
--chart-online: #8b3fb5;
--chart-counter: #e0bdf0; /* Lighter tint of primary purple */
}
.chart-block { display: flex; flex-direction: column; gap: 16px; }
/* ── The plot ───────────────────────────────────────────────────────────────
Two stacked rows — plot and day labels — so the container's height INCLUDES
the x-axis band. A fixed height around the plot alone is what gives a card
its own little nested scrollbar. */
.plot {
position: relative;
display: grid;
grid-template-rows: 200px auto;
padding-left: 46px; /* the axis gutter the tick labels sit in */
}
/* The grid is drawn under the marks: hairline, solid, one step off the
surface. Never dashed — dashing reads as "threshold" when it is just a
grid. */
.plot-grid {
grid-row: 1;
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.plot-gridline {
position: relative;
border-top: 0;
}
.plot-tick {
position: absolute;
right: calc(100% + 8px);
top: -0.55em;
font: 400 10.5px/1 var(--font-sans);
color: var(--color-ink-4);
font-variant-numeric: tabular-nums; /* a column of numbers: tabular */
white-space: nowrap;
}
.plot-cols {
grid-row: 1;
grid-column: 1;
display: flex;
align-items: flex-end;
gap: 4px;
min-width: 0;
}
.plot-col {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
border: 0;
background: none;
}
.plot-col:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* The stack. Capped at 24px — a column that fills its slot leaves no air, and
the leftover band is what makes the hover target bigger than the mark. */
.plot-stack {
display: flex;
flex-direction: column-reverse; /* counter at the base, online on top */
width: 100%;
max-width: 28px;
min-height: 2px;
gap: 0; /* removed surface gap between segments */
transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease;
border-radius: 6px 6px 0 0;
}
.plot-col[data-hover='true'] .plot-stack { opacity: 0.78; }
.plot-seg { min-height: 0; }
/* 4px rounded data-end, square at the baseline: only the top of the stack is
rounded, and only because it is the top. */
.plot-stack > .plot-seg:last-child { border-radius: 4px 4px 0 0; }
.plot-seg[data-kind='online'] { background: var(--chart-online); }
.plot-seg[data-kind='counter'] { background: var(--chart-counter); }
/* One direct label, on the tallest column. Selective by design — a number on
every column is chaos and goes unread. */
.plot-peak {
position: absolute;
left: 50%;
transform: translate(-50%, -4px);
font: 600 10.5px/1 var(--font-sans);
color: var(--color-ink-2); /* a text token, never the series colour */
white-space: nowrap;
pointer-events: none;
}
/* ── Tooltip ────────────────────────────────────────────────────────────── */
.plot-tip {
position: absolute;
/* Pinned to the top of the PLOT, not floated above the bar.
Anchored to the bar it was clipped: the panel carries `overflow: hidden`
so its header band cannot square off the card corners, and the tallest
column's tooltip ran straight through the top edge and lost its first
row. Hanging from the plot ceiling it is always inside the card, whatever
the column height, and the hovered column is tinted so the pairing is
still obvious. */
top: 0;
left: 50%;
transform: translate(-50%, 0);
z-index: 5;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 152px;
padding: 9px 11px;
border: var(--card-border);
border-radius: var(--card-radius-sm);
background: var(--card-bg);
box-shadow: 0 8px 24px -8px rgb(15 23 42 / 0.18);
pointer-events: none;
}
.plot-tip-day {
font: 600 11.5px/1.2 var(--font-sans);
color: var(--color-ink-1);
padding-bottom: 3px;
border-bottom: 1px solid var(--color-line);
}
.plot-tip-row {
display: flex;
align-items: center;
gap: 7px;
font: 400 11.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
white-space: nowrap;
}
.plot-tip-row b {
margin-left: auto;
font-weight: 600;
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
}
.chart2 {
display: flex; align-items: flex-end; gap: 10px; height: 190px; overflow-x: auto;
border-left: 1px solid var(--color-line); border-bottom: 1px solid var(--color-line); padding: 0 6px;
}
.chart2-col { flex: 1 0 34px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 6px; }
.chart2-bars { display: flex; align-items: flex-end; gap: 3px; height: 100%; width: 100%; justify-content: center; }
.chart2-bar { width: 11px; border-radius: 4px 4px 0 0; min-height: 2px; transition: opacity .15s ease; }
.chart2-bar[data-kind="online"] { background: var(--color-brand); }
/* The second channel is the same hue at lower weight, so the pair reads as one
family rather than two unrelated categories. */
.chart2-bar[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.chart2-col:hover .chart2-bar { opacity: .78; }
.chart2-label { font: 500 10.5px/1 var(--font-sans); color: var(--color-ink-4); white-space: nowrap; }
.chart-legend { grid-column: 2; display: flex; gap: 16px; padding-top: 6px; }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; font: 400 12px/1 var(--font-sans); color: var(--color-ink-3); }
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind="online"] { background: var(--color-brand); }
.chart-legend i[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
.donut-wrap { display: flex; align-items: center; justify-content: center; }
.donut { position: relative; width: 160px; height: 160px; }
.donut svg { width: 100%; height: 100%; }
.donut-track { fill: none; stroke: var(--color-surface-subtle); stroke-width: 18; }
.donut-fill { fill: none; stroke: var(--color-brand); stroke-width: 18; transition: stroke-dasharray .4s ease; }
.donut-centre { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 2px; }
.donut-centre span { font: 400 11px/1 var(--font-sans); color: var(--color-ink-3); }
.donut-centre strong { font: 600 18px/1.2 var(--font-sans); color: var(--color-ink-1); font-variant-numeric: tabular-nums; }
.donut-key { width: 10px; height: 10px; border-radius: 999px; flex: none; }
.donut-key[data-kind="online"] { background: var(--color-brand); }
.donut-key[data-kind="counter"] { background: color-mix(in srgb, var(--color-brand) 32%, white); }
/* The channel split, on one line beneath the card. Two columns so the pair
sits square against each other; stacked only when there is genuinely no room
for both. */
.split-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding-top: 14px;
.plot-tip-row[data-total='true'] {
padding-top: 3px;
border-top: 1px solid var(--color-line);
}
.plot-tip-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.plot-tip-row i[data-kind='online'] { background: var(--chart-online); }
.plot-tip-row i[data-kind='counter'] { background: var(--chart-counter); }
/* Flip at the edges so the first and last day's tooltip stays on the card. */
.plot-col:first-child .plot-tip { left: 0; transform: translate(0, 0); }
.plot-col:last-child .plot-tip { left: auto; right: 0; transform: translate(0, 0); }
/* ── Day labels ─────────────────────────────────────────────────────────── */
.plot-days {
grid-row: 2;
grid-column: 1;
display: flex;
gap: 4px;
padding-top: 8px;
}
.plot-day {
flex: 1 1 0;
min-width: 0;
text-align: center;
font: 400 10.5px/1.2 var(--font-sans);
color: var(--color-ink-4);
white-space: nowrap;
}
/* ── Legend and the table toggle ────────────────────────────────────────── */
.chart-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.chart-legend { display: flex; gap: 16px; }
.chart-legend span {
display: inline-flex; align-items: center; gap: 6px;
font: 400 12px/1 var(--font-sans); color: var(--color-ink-3);
}
.chart-legend i { width: 9px; height: 9px; border-radius: 3px; }
.chart-legend i[data-kind='online'] { background: var(--chart-online); }
.chart-legend i[data-kind='counter'] { background: var(--chart-counter); }
.chart-toggle {
border: 0; background: none; padding: 0; cursor: pointer;
font: 600 12px/1 var(--font-sans); color: var(--color-brand);
}
.chart-toggle:hover { text-decoration: underline; }
.chart-toggle:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 3px; }
/* ── Table view ─────────────────────────────────────────────────────────── */
.chart-table { max-height: 232px; overflow-y: auto; }
.chart-table table { width: 100%; border-collapse: collapse; }
.chart-table th {
position: sticky; top: 0;
text-align: left; padding: 7px 10px;
background: var(--color-surface-subtle);
font: 600 10.5px/1 var(--font-sans);
letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-ink-3);
border-bottom: 1px solid var(--color-line);
}
.chart-table td {
padding: 8px 10px;
font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-1);
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 55%, transparent);
}
.chart-table tr:last-child td { border-bottom: 0; }
.chart-table .num { text-align: right; font-variant-numeric: tabular-nums; }
/* ── The channel split ──────────────────────────────────────────────────────
One bar, then the two figures. This is what a 160px two-slice donut was
doing, in the form that can be read rather than estimated. */
.split {
display: flex; flex-direction: column; gap: 10px;
padding-top: 14px; border-top: 1px solid var(--color-line);
}
.split-bar { display: flex; height: 10px; gap: 2px; }
.split-bar span:first-child { border-radius: 999px 0 0 999px; }
.split-bar span:last-child { border-radius: 0 999px 999px 0; }
.split-bar span[data-kind='online'] { background: var(--chart-online); }
.split-bar span[data-kind='counter'] { background: var(--chart-counter); }
.split-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@media (max-width: 520px) { .split-row { grid-template-columns: minmax(0, 1fr); } }
.share {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--color-line); border-radius: 12px;
background: var(--color-surface-subtle);
border: var(--card-border); border-radius: var(--card-radius-sm);
background: var(--card-bg-subtle);
}
.share .donut-key { align-self: center; }
.share-key { width: 10px; height: 10px; border-radius: 999px; flex: none; align-self: center; }
.share-key[data-kind='online'] { background: var(--chart-online); }
.share-key[data-kind='counter'] { background: var(--chart-counter); }
.share-label { font: 400 12.5px/1 var(--font-sans); color: var(--color-ink-3); }
/* The percentage leads: the question the split answers is "how much of my
trade comes through each", and the rupee figure is already in the KPI card
above. */
above. Proportional figures, not tabular — this is a standalone value, not a
column of them. */
.share-pct { font: 600 18px/1 var(--font-sans); color: var(--color-ink-1); margin-left: auto; }
.share-amt {
font: 500 13px/1 var(--font-sans); color: var(--color-brand);
font-variant-numeric: tabular-nums;
.share-amt { font: 500 13px/1 var(--font-sans); color: var(--color-ink-2); font-variant-numeric: tabular-nums; }
@media (max-width: 600px) {
.plot { padding-left: 40px; grid-template-rows: 170px auto; }
}
@media (prefers-reduced-motion: reduce) {
.plot-stack { transition: none; }
}
/* ── Store health ───────────────────────────────────────────────────────── */
@@ -236,8 +486,8 @@
.quick-list { display: flex; flex-direction: column; gap: 8px; }
.quick {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
padding: 11px 12px; border: 1px solid var(--color-line); border-radius: 12px;
text-decoration: none; background: var(--color-surface);
padding: 11px 12px; border: var(--card-border); border-radius: var(--card-radius-sm);
text-decoration: none; background: var(--card-bg);
}
.quick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.quick-note { font: 400 11.5px/1.3 var(--font-sans); color: var(--color-ink-3); }
@@ -265,31 +515,22 @@
.heartbeat[data-tone="critical"] i { background: #b3261e; }
.till-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.chip-row { display: inline-flex; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: 999px; cursor: pointer; border: 1px solid var(--color-line);
background: var(--color-surface); font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2);
}
.chip b { font-weight: 600; opacity: .7; }
.chip[data-active="true"] { background: var(--color-brand); border-color: var(--color-brand); color: #fff; }
.chip[data-tone="attention"][data-active="true"] { background: #8a5a00; border-color: #8a5a00; }
.chip[data-tone="healthy"][data-active="true"] { background: #1c6b47; border-color: #1c6b47; }
.chip:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
/* `.chip` and `.chip-row` were here — the filter strip on the Console’s Till
sync panel and on Counters. Both render `components/TabBar` at `size="sm"`
now, the same control as the Sales status ladder.
.search {
display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; height: 32px;
min-width: 220px; border: 1px solid var(--color-line); border-radius: 10px;
background: var(--color-surface); color: var(--color-ink-4);
}
.search input { border: 0; outline: 0; background: none; flex: 1; min-width: 0; font: 400 13px/1 var(--font-sans); color: var(--color-ink-1); }
.search:focus-within { border-color: var(--color-brand); }
The tones went with them: green on Healthy, amber on Attention, red on
Offline. They only ever applied to the SELECTED chip, so a strip at rest
was plain pills anyway, and this console’s position is that it does not
colour-code severity — see the note where `BUCKET_COLOR` used to be in
`terminalProblems.ts`. The label and the count say which rung is bad. */
/* ── Segmented control ──────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border: 1px solid var(--color-line); border-radius: 10px; background: var(--color-surface-subtle); }
.seg-btn { border: 0; background: none; padding: 6px 14px; border-radius: 8px; font: 500 12.5px/1 var(--font-sans); color: var(--color-ink-2); cursor: pointer; }
.seg-btn[data-active="yes"] { background: var(--color-brand); color: #fff; }
.seg-btn:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* `.search` was here — the Console and Counters each drew a label-wrapping-an-
input version of the search box. Both use `components/SearchInput` now. */
/* The `.seg` segmented control was here — an eighth treatment of "tab", used by
the Sales overview's Revenue / Orders switch. That switch is
`components/TabBar` at `size="sm"` now, like every other one in the console. */
/* ── Needs your attention ───────────────────────────────────────────────── */
.attention-head { display: flex; gap: 12px; align-items: flex-start; }
@@ -303,7 +544,7 @@
.attention-card {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 12px;
align-items: center; padding: 13px 14px; border: 1px solid var(--color-line); border-radius: 13px;
align-items: center; padding: 13px 14px; border: var(--card-border); border-radius: var(--card-radius);
}
.attention-card[data-tone="critical"] { background: #fdefee; border-color: #f2cbc7; }
.attention-card[data-tone="attention"] { background: #fdf6e8; border-color: #eeddb4; }
@@ -324,13 +565,15 @@
/* ── Loading ────────────────────────────────────────────────────────────── */
@keyframes sk-pulse { 0%, 100% { opacity: .55; } 50% { opacity: .9; } }
.sk-card, .sk-chart {
border: 1px solid var(--color-line); border-radius: 16px;
background: var(--color-surface-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
border: var(--card-border); border-radius: var(--card-radius);
background: var(--card-bg-subtle); animation: sk-pulse 1.3s ease-in-out infinite;
}
.sk-card { height: 104px; }
.sk-chart { height: 300px; }
@media (prefers-reduced-motion: reduce) {
.sk-card, .sk-chart, .console-live i { animation: none; }
.chart2-bar, .donut-fill { transition: none; }
/* `.chart2-bar` and `.donut-fill` were named here. Both are gone — the chart
is stacked columns and the split is a bar, not a ring — and the column's
own transition is handled beside it, in the Sales overview block. */
}

View File

@@ -29,7 +29,7 @@ export interface BranchOverviewProps {
export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
if (rows.length === 0) {
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<Text type="body" size="sm" color="secondary">
No branches are set up yet.
</Text>
@@ -42,7 +42,7 @@ export function BranchOverview({ rows, onSelect }: BranchOverviewProps) {
);
return (
<Card padding={0} elevation="low">
<Card padding={0}>
<div className="table-scroll">
<table className="branch-table">
<thead>

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Ban, CloudUpload, ListChecks, Receipt, ShoppingCart } from 'lucide-react';
import { KpiCard } from '@/components/KpiCard';
import { count, money } from '@/features/store-admin/format';
import type { ConsoleTotals } from '../consoleModel';
@@ -39,15 +39,15 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
return (
<div className="kpi-strip">
<Kpi icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
<Kpi icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
<Kpi
<KpiCard icon={<ShoppingCart size={17} />} label="Online Sales" value={money(totals.onlineRevenue)} />
<KpiCard icon={<Receipt size={17} />} label="Counter Sales" value={money(totals.counterRevenue)} />
<KpiCard
icon={<ListChecks size={17} />}
label="Total Orders"
value={count(totals.totalOrders)}
note={`${count(totals.onlineOrders)} app · ${count(totals.counterBills)} counter`}
/>
<Kpi
<KpiCard
icon={<Ban size={17} />}
label="Cancelled Orders"
value={count(totals.cancelled)}
@@ -55,7 +55,7 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
? { note: `${Math.round((totals.cancelled / totals.onlineOrders) * 100)}% of app orders` }
: {})}
/>
<Kpi
<KpiCard
icon={<CloudUpload size={17} />}
label="Unsynced Bills"
value={count(totals.pendingBills)}
@@ -65,22 +65,3 @@ export function KpiStrip({ totals, isLoading }: KpiStripProps) {
);
}
function Kpi({
icon, label, value, note,
}: {
icon: ReactNode;
label: string;
value: string;
note?: string;
}) {
return (
<article className="kpi">
<span className="kpi-icon" aria-hidden>{icon}</span>
<div className="kpi-head">
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
{note ? <span className="kpi-note">{note}</span> : null}
</div>
</article>
);
}

View File

@@ -1,20 +1,37 @@
import { useMemo, useState } from 'react';
import { Tab, TabBar } from '@/components/TabBar';
import { money, count } from '@/features/store-admin/format';
import type { DaySeries } from '../useConsoleSeries';
/**
* Revenue and orders over the period: the shape on the left, the split on the
* right.
* Revenue and orders over the period.
*
* Two views of one dataset, deliberately. The columns answer "when did it
* happen"; the ring answers "where did it come from". Either alone leaves a
* shopkeeper doing arithmetic in their head — a good week of counter sales and
* a collapse in app orders look identical in a single total.
* ── What this replaced, and why each piece went ─────────────────────────────
*
* Drawn by hand rather than with a charting library. It is a few dozen columns
* and one ring; a library would be more code shipped than the chart it draws,
* and this way the bars use the same brand token as everything else on the page
* instead of a palette of its own.
* **The donut is gone.** It was a ring showing one share — online against
* counter — with the same two figures spelled out in a row directly beneath
* it. A two-slice ring is the textbook case for "the number is the chart": it
* spends 160×160px to say what "68% / 32%" says in nine characters, and it says
* it less precisely. The share is now a single slim bar above the two figures,
* which is the form for part-to-whole and doubles as the legend key.
*
* **The columns are stacked, not grouped.** Two thin bars per day meant 60
* marks across a 30-day range at ~11px each. Stacked, a column's height is the
* day's takings — the thing a shopkeeper actually reads off this chart — and
* the split is still visible inside it.
*
* **The colours are measured, not chosen.** The old pair was the brand purple
* and the brand at 32% on white, and running them through the palette checks
* failed three ways: the brand sits at OKLCH L 0.406, below the 0.43 floor for
* a chart mark; the 32% mix landed at chroma 0.047, which reads as grey rather
* than as a colour; and it cleared only 1.82:1 against white, under the 3:1
* minimum. So the second channel was a mark you could not reliably see. The
* pair below is validated — see `--chart-online` in `console.css`.
*
* **There is a real tooltip.** Values used to live in `title` attributes: a
* browser tooltip after a second's delay, unstyled, invisible on touch. And a
* table view, because with a stacked column the per-channel figure for one day
* exists nowhere else on the page — a tooltip must enhance, never gate.
*/
export interface SalesOverviewProps {
days: readonly DaySeries[];
@@ -25,6 +42,8 @@ type Measure = 'revenue' | 'orders';
export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
const [measure, setMeasure] = useState<Measure>('revenue');
const [isTable, setIsTable] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
const series = useMemo(
() =>
@@ -36,16 +55,41 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
[days, measure],
);
/* The axis tops out at a round number at or above the tallest column, so the
ticks read 0 / 5,000 / 10,000 rather than 0 / 4,317 / 8,634. */
const peak = useMemo(
() => Math.max(1, ...series.map((d) => Math.max(d.online, d.counter))),
() => Math.max(1, ...series.map((d) => d.online + d.counter)),
[series],
);
const axisTop = useMemo(() => niceCeiling(peak), [peak]);
const ticks = useMemo(
() => [1, 0.75, 0.5, 0.25, 0].map((step) => Math.round(axisTop * step)),
[axisTop],
);
/* Labelled directly, because it is the one column worth naming. Everything
else is carried by the axis, the tooltip and the table. */
const peakIndex = useMemo(() => {
let best = -1;
let bestTotal = 0;
series.forEach((d, i) => {
const total = d.online + d.counter;
if (total > bestTotal) {
bestTotal = total;
best = i;
}
});
return best;
}, [series]);
const online = series.reduce((sum, d) => sum + d.online, 0);
const counter = series.reduce((sum, d) => sum + d.counter, 0);
const total = online + counter;
const onlineShare = total > 0 ? Math.round((online / total) * 100) : 0;
const format = (value: number) => (measure === 'revenue' ? money(value) : count(value));
const axisFormat = (value: number) =>
measure === 'revenue' ? compactMoney(value) : count(value);
if (isLoading) return <div className="sk-chart" aria-busy="true" />;
@@ -62,21 +106,14 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
: `Revenue and orders for the last ${days.length} days`}
</p>
</div>
<div className="seg" role="tablist" aria-label="Measure">
{(['revenue', 'orders'] as const).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={measure === m}
className="seg-btn"
data-active={measure === m ? 'yes' : 'no'}
onClick={() => setMeasure(m)}
>
{m === 'revenue' ? 'Revenue' : 'Orders'}
</button>
))}
</div>
<TabBar size="sm" aria-label="Measure">
<Tab
label="Revenue"
isActive={measure === 'revenue'}
onClick={() => setMeasure('revenue')}
/>
<Tab label="Orders" isActive={measure === 'orders'} onClick={() => setMeasure('orders')} />
</TabBar>
</header>
{total === 0 ? (
@@ -85,83 +122,146 @@ export function SalesOverview({ days, isLoading }: SalesOverviewProps) {
<span>Pick a wider date range, or check back once the shop has taken an order.</span>
</div>
) : (
<div className="sales-split">
<div className="chart-wrap">
{/* The axis is drawn from the data rather than fixed, so a shop
taking ₹800 a day gets a readable scale instead of a flat line
along the bottom of a ₹20K grid. */}
<div className="chart-axis" aria-hidden>
{[1, 0.75, 0.5, 0.25, 0].map((step) => (
<span key={step}>{format(Math.round(peak * step))}</span>
))}
</div>
<div className="chart2">
{series.map((d) => (
<div key={d.day} className="chart2-col">
<div className="chart2-bars">
<span
className="chart2-bar"
data-kind="online"
style={{ height: `${(d.online / peak) * 100}%` }}
title={`${shortDay(d.day)} · Online ${format(d.online)}`}
/>
<span
className="chart2-bar"
data-kind="counter"
style={{ height: `${(d.counter / peak) * 100}%` }}
title={`${shortDay(d.day)} · Counter ${format(d.counter)}`}
/>
<div className="chart-block">
{isTable ? (
<SeriesTable rows={series} format={format} />
) : (
<div
className="plot"
onMouseLeave={() => setHovered(null)}
role="img"
aria-label={`${measure === 'revenue' ? 'Revenue' : 'Orders'} by day, online and counter`}
>
{/* The grid is drawn first and sits under the marks: hairline,
solid, one step off the surface. Its rows and the axis labels
come from the same tick list, so they cannot drift apart. */}
<div className="plot-grid" aria-hidden>
{ticks.map((tick) => (
<div key={tick} className="plot-gridline">
<span className="plot-tick">{axisFormat(tick)}</span>
</div>
<span className="chart2-label">{shortDay(d.day)}</span>
</div>
))}
</div>
))}
</div>
<div className="chart-legend">
<span><i data-kind="online" />Online</span>
<span><i data-kind="counter" />Counter</span>
<div className="plot-cols">
{series.map((d, index) => {
const dayTotal = d.online + d.counter;
return (
<div
key={d.day}
className="plot-col"
data-hover={hovered === index}
onMouseEnter={() => setHovered(index)}
onFocus={() => setHovered(index)}
onBlur={() => setHovered(null)}
tabIndex={0}
aria-label={`${longDay(d.day)}: online ${format(d.online)}, counter ${format(d.counter)}`}
>
{/* The stack. Counter sits under online so the brand
colour caps the column, and the two are separated by a
gap in the surface rather than by a stroke. */}
<div className="plot-stack" style={{ height: `${(dayTotal / axisTop) * 100}%` }}>
<span
className="plot-seg"
data-kind="online"
style={{ flexBasis: `${pct(d.online, dayTotal)}%` }}
/>
<span
className="plot-seg"
data-kind="counter"
style={{ flexBasis: `${pct(d.counter, dayTotal)}%` }}
/>
</div>
{index === peakIndex ? (
<span className="plot-peak" style={{ bottom: `${(dayTotal / axisTop) * 100}%` }}>
{format(dayTotal)}
</span>
) : null}
{hovered === index ? (
<div className="plot-tip" role="status">
<span className="plot-tip-day">{longDay(d.day)}</span>
<span className="plot-tip-row">
<i data-kind="online" />
Online
<b>{format(d.online)}</b>
</span>
<span className="plot-tip-row">
<i data-kind="counter" />
Counter
<b>{format(d.counter)}</b>
</span>
<span className="plot-tip-row" data-total="true">
Total<b>{format(dayTotal)}</b>
</span>
</div>
) : null}
</div>
);
})}
</div>
{/* Every date is drawn — see `showLabel`. */}
<div className="plot-days" aria-hidden>
{series.map((d) => (
<span key={d.day} className="plot-day">
{showLabel() ? shortDay(d.day) : ''}
</span>
))}
</div>
</div>
)}
<div className="chart-foot">
<div className="chart-legend">
<span>
<i data-kind="online" />
Online
</span>
<span>
<i data-kind="counter" />
Counter
</span>
</div>
<button type="button" className="chart-toggle" onClick={() => setIsTable((v) => !v)}>
{isTable ? 'Show chart' : 'Show table'}
</button>
</div>
<div className="donut-wrap">
<Donut share={onlineShare} total={format(total)} />
{/* The channel split: one bar, then the figures. This is what the
donut was for, in a form that can be read rather than estimated. */}
<div className="split">
<div className="split-row">
<Share kind="online" label="Online" percent={onlineShare} amount={format(online)} />
<Share
kind="counter"
label="Counter"
percent={100 - onlineShare}
amount={format(counter)}
/>
</div>
</div>
</div>
)}
{/* The channel split, on one line under the whole card.
It was a stacked column beside the ring, which put the two figures a
shopkeeper compares — online against counter — one above the other in
a narrow gutter. Side by side they can be read against each other at a
glance, and the row runs the full width rather than being squeezed
into whatever the ring left over. */}
{total > 0 ? (
<div className="split-row">
<Share
kind="online"
label="Online Sales"
percent={onlineShare}
amount={format(online)}
/>
<Share
kind="counter"
label="Counter Sales"
percent={100 - onlineShare}
amount={format(counter)}
/>
</div>
) : null}
</section>
);
}
function Share({
kind, label, percent, amount,
}: { kind: 'online' | 'counter'; label: string; percent: number; amount: string }) {
kind,
label,
percent,
amount,
}: {
kind: 'online' | 'counter';
label: string;
percent: number;
amount: string;
}) {
return (
<div className="share">
<span className="donut-key" data-kind={kind} />
<span className="share-key" data-kind={kind} />
<span className="share-label">{label}</span>
<strong className="share-pct">{percent}%</strong>
<span className="share-amt">{amount}</span>
@@ -170,42 +270,101 @@ function Share({
}
/**
* The channel split as a ring.
* The same numbers as a table.
*
* One circle with a dash offset rather than two arcs: a single stroke cannot
* develop a seam at the join, and the whole thing animates from one number.
* Not a nicety: in a stacked column the per-channel figure for a single day is
* otherwise only in the hover tooltip, which is no use on a touch screen, to a
* screen reader, or to anyone printing the page.
*/
function Donut({ share, total }: { share: number; total: string }) {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const filled = (share / 100) * circumference;
function SeriesTable({
rows,
format,
}: {
rows: readonly { day: string; online: number; counter: number }[];
format: (value: number) => string;
}) {
return (
<div className="donut">
<svg viewBox="0 0 140 140" role="img" aria-label={`Online sales ${share}% of the total`}>
<circle cx="70" cy="70" r={radius} className="donut-track" />
<circle
cx="70"
cy="70"
r={radius}
className="donut-fill"
strokeDasharray={`${filled} ${circumference - filled}`}
// Start at twelve o'clock; the default is three, which reads as a
// slice that has already been eaten.
transform="rotate(-90 70 70)"
/>
</svg>
<div className="donut-centre">
<span>Total Sales</span>
<strong>{total}</strong>
</div>
<div className="chart-table">
<table>
<thead>
<tr>
<th>Day</th>
<th className="num">Online</th>
<th className="num">Counter</th>
<th className="num">Total</th>
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.day}>
<td>{longDay(d.day)}</td>
<td className="num">{format(d.online)}</td>
<td className="num">{format(d.counter)}</td>
<td className="num">
<strong>{format(d.online + d.counter)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ── Helpers ─────────────────────────────────────────────────────────────── */
const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0);
/**
* The next round number at or above `value` — 1, 2 or 5 times a power of ten.
*
* An axis whose top is the tallest column produces ticks like 4,317 and 8,634,
* which nobody can read a bar against. This is what makes them 5,000 and
* 10,000.
*/
function niceCeiling(value: number): number {
if (value <= 0) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalised = value / magnitude;
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
return step * magnitude;
}
/** "₹12.4K" for an axis tick, where the full figure would not fit the gutter. */
function compactMoney(value: number): string {
if (value >= 10_000_000) return `₹${(value / 10_000_000).toFixed(1).replace(/\.0$/, '')}Cr`;
if (value >= 100_000) return `₹${(value / 100_000).toFixed(1).replace(/\.0$/, '')}L`;
if (value >= 1_000) return `₹${(value / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return money(value);
}
/**
* Which x labels to draw.
*
* Currently: all of them. The thinning this used to do — keep about six,
* always including the first and the last — is gone by choice, so every date
* is drawn.
*
* It keeps its own function rather than being inlined at the call site, so the
* decision has somewhere to live and thinning is one edit away if a 30-day
* range turns out to overlap. It takes NO arguments: it was passed the index
* and the length, and with the body returning a constant those became unused
* parameters, which `noUnusedParameters` fails the build on.
*/
function showLabel(): boolean {
return true;
}
/** "2026-09-03" → "Sep 3". Falls back to the raw value rather than throwing. */
function shortDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { month: 'short', day: 'numeric' });
}
/** "2026-09-03" → "Wed, 3 Sep" — the tooltip and table have room for the day. */
function longDay(day: string): string {
const parsed = new Date(day);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric', month: 'short' });
}

View File

@@ -6,7 +6,7 @@ import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Boxes, PackageSearch, Store } from 'lucide-react';
import type { TenantInfo } from '@/api/types';
import { count, money } from '@/features/store-admin/format';
import { money, plural } from '@/features/store-admin/format';
import type { BranchRow } from '../consoleModel';
/**
@@ -29,7 +29,7 @@ export interface ShopSummaryProps {
export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
if (!row) {
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<Text type="body" size="sm" color="secondary">
No branch is selected.
</Text>
@@ -46,7 +46,7 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
const total = row.onlineRevenue + row.counterRevenue;
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={3}>
<HStack gap={2} align="start" wrap="wrap">
<span className="shop-mark" aria-hidden>
@@ -78,8 +78,8 @@ export function ShopSummary({ row, shop, base }: ShopSummaryProps) {
</HStack>
<div className="shop-figures">
<Figure label="Online" value={money(row.onlineRevenue)} note={`${count(row.onlineOrders)} orders`} />
<Figure label="Counter" value={money(row.counterRevenue)} note={`${count(row.counterBills)} bills`} />
<Figure label="Online" value={money(row.onlineRevenue)} note={`${plural(row.onlineOrders, 'order')}`} />
<Figure label="Counter" value={money(row.counterRevenue)} note={`${plural(row.counterBills, 'bill')}`} />
<Figure label="Total" value={money(total)} note="this period" isStrong />
</div>

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Boxes, ClipboardList, Monitor, Store } from 'lucide-react';
import { count } from '@/features/store-admin/format';
import { count, plural } from '@/features/store-admin/format';
import type { BranchRow, ConsoleTotals, HealthTone } from '../consoleModel';
/**
@@ -96,7 +96,7 @@ export function StoreHealth({
detail={
productCount === 0
? 'No products yet, so shoppers find the shop empty'
: `${count(productCount)} products`
: `${plural(productCount, 'product')}`
}
tone={productCount === 0 ? 'attention' : withoutTill > 0 ? 'attention' : 'healthy'}
action="Manage"

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Search } from 'lucide-react';
import { Tab, TabBar } from '@/components/TabBar';
import { SearchInput } from '@/components/SearchInput';
import { branchLabel, count, money } from '@/features/store-admin/format';
import { SYNC_LABEL, shortAge, type TerminalStatus } from '@/features/store-admin/posStatus';
import type { BranchRow, HealthTone } from '../consoleModel';
@@ -99,27 +100,34 @@ export function TillSync({ rows, showBranch, base }: TillSyncProps) {
</div>
<div className="till-controls">
<div className="chip-row">
<button type="button" className="chip" data-active={filter === 'all'} onClick={() => setFilter('all')}>
All <b>{tills.length}</b>
</button>
<button type="button" className="chip" data-tone="attention" data-active={filter === 'attention'} onClick={() => setFilter('attention')}>
Attention <b>{needing}</b>
</button>
<button type="button" className="chip" data-tone="healthy" data-active={filter === 'healthy'} onClick={() => setFilter('healthy')}>
Healthy <b>{healthy}</b>
</button>
</div>
<label className="search">
<Search size={14} aria-hidden />
<input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={showBranch ? 'Search till ID, branch…' : 'Search till ID…'}
aria-label="Search tills"
<TabBar size="sm" aria-label="Filter tills">
<Tab
label="All"
count={tills.length}
isActive={filter === 'all'}
onClick={() => setFilter('all')}
/>
</label>
<Tab
label="Attention"
count={needing}
isActive={filter === 'attention'}
onClick={() => setFilter('attention')}
/>
<Tab
label="Healthy"
count={healthy}
isActive={filter === 'healthy'}
onClick={() => setFilter('healthy')}
/>
</TabBar>
<SearchInput
label="Search tills"
value={term}
onChange={setTerm}
placeholder={showBranch ? 'Till ID, branch…' : 'Till ID…'}
width={220}
/>
</div>
</header>

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import type { DateRange } from '@/api/insights';
import { useOrders } from '@/queries/hooks';
import type { PosSalesSummary } from '@/api/types';
import { isCounterSale } from '@/features/store-admin/branchStats';
/**
* The day-by-day series behind the chart and the donut.
@@ -63,6 +64,20 @@ export function useConsoleSeries(
const amount = amountOf(order);
const isCancelled = String(order.orderstatus ?? '').toLowerCase() === 'cancelled';
// A counter bill imported from a till sheet is in `orders` like any
// other row, tagged OFFLINE by the backend, and counting it here put
// the shop's counter takings on the app line of this chart and in the
// "Online Sales" figure above it. The POS loop below adds the tills
// that synced; these are the same money arriving the other way.
if (isCounterSale(order)) {
if (day && !isCancelled) {
const row = at(day);
row.counter += amount;
row.counterBills += 1;
}
continue;
}
onlineOrders += 1;
if (isCancelled) cancelled += 1;
else onlineRevenue += amount;

View File

@@ -1,25 +0,0 @@
import { AppShell, type NavEntry } from '@/components/shell/AppShell';
import { DateScopeProvider } from '@/components/shell/DateScope';
/**
* Nearle Admin — the platform workspace.
*
* No "Onboard branch" here on purpose. Opening an outlet is the merchant's
* decision about their own business, so it sits in the Store Admin workspace.
* We onboard the tenant; they onboard their branches.
*/
const NAV: readonly NavEntry[] = [
{ to: '/nearle/stores', label: 'Stores' },
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
{ to: '/nearle/catalogue', label: 'Global catalogue' },
{ to: '/nearle/partners', label: 'Rider partners' },
{ to: '/nearle/uploads', label: 'Uploads' },
];
export function NearleAdminShell() {
return (
<DateScopeProvider>
<AppShell nav={NAV} home="/nearle/stores" navLabel="Nearle Admin" />
</DateScopeProvider>
);
}

View File

@@ -1,209 +0,0 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Check, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { tenantsApi } from '@/api/tenants';
import { useAllPartners, useAppRegions } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
/**
* Which delivery partner supplies a merchant's riders.
*
* ── Why this is the platform's decision ─────────────────────────────────────
*
* `partnerid` is kept out of the merchant-editable allowlist on the server, so
* this cannot be done from the shop's own profile — a merchant who could set it
* would move themselves under another partner's riders and billing. It is
* changed here, by whoever is looking at that merchant's record.
*
* ── Why "own riders" is an option and not an absence ────────────────────────
*
* Sending `partnerid: 0` is a real instruction: it means the shop delivers with
* riders it hired itself. The server reads a zero as sent rather than as a
* missing field, which is exactly why the endpoint is separate — everywhere
* else in the tenant API a zero means "not supplied", and there it would
* silently unassign somebody.
*
* One partner per merchant, which is what `tenants.partnerid` allows and what
* the assign picker later branches on. A partner serving several merchants is
* the ordinary case in the other direction: 44 supplies 48 shops.
*/
export function PartnerAssignDrawer({
tenantid,
tenantname,
currentPartnerId,
onClose,
}: {
tenantid: number;
tenantname: string;
currentPartnerId: number;
onClose: () => void;
}) {
const client = useQueryClient();
const partners = useAllPartners();
const regions = useAppRegions();
const [chosen, setChosen] = useState<number>(currentPartnerId);
const [error, setError] = useState<string | null>(null);
const regionName = useMemo(() => {
const map = new Map<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const save = useMutation({
mutationFn: () => tenantsApi.assignPartner(tenantid, chosen),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
onClose();
},
onError: (cause) => setError(errorMessage(cause)),
});
const current = partners.data.find((p) => p.partnerid === currentPartnerId);
return (
<Drawer
title="Delivery partner"
subtitle={tenantname}
width={460}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : 'Save'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={save.isPending || chosen === currentPartnerId}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<Note icon={<Bike size={15} />}>
A partner supplies riders to this shop. With one set, the assign screen offers the
partner’s riders alongside any the shop hired itself; without one, only its own.
</Note>
<Section title="Currently">
<DrawerCard>
<Row
label="Partner"
value={current?.partnername ?? (currentPartnerId > 0 ? `Partner ${currentPartnerId}` : 'Own riders only')}
/>
</DrawerCard>
</Section>
<Section title="Change to">
<DrawerCard>
{/* "Own riders" first and always present. It is not the empty state
— a shop that hires its own riders is a real arrangement, and
the option has to be as reachable as any partner. */}
<Choice
label="Own riders only"
detail="This shop delivers with riders it hired itself."
isChosen={chosen === 0}
onChoose={() => setChosen(0)}
/>
{partners.isLoading ? (
<Row label="Partners" value="Reading…" />
) : (
partners.data.map((partner) => (
<Choice
key={partner.partnerid}
label={partner.partnername ?? `Partner ${partner.partnerid}`}
detail={
[
regionName.get(partner.applocationid ?? 0),
partner.companyname,
]
.filter(Boolean)
.join(' · ') || 'No region recorded'
}
isChosen={chosen === partner.partnerid}
onChoose={() => setChosen(partner.partnerid)}
/>
))
)}
</DrawerCard>
</Section>
</VStack>
</Drawer>
);
}
function Choice({
label,
detail,
isChosen,
onChoose,
}: {
label: string;
detail: string;
isChosen: boolean;
onChoose: () => void;
}) {
return (
<button
type="button"
onClick={onChoose}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
padding: '12px 14px',
border: 0,
borderTop: '1px solid var(--color-line)',
background: isChosen ? 'var(--color-brand-tint)' : 'transparent',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span
aria-hidden
style={{
width: 18,
height: 18,
flex: 'none',
borderRadius: 999,
display: 'grid',
placeItems: 'center',
border: isChosen ? 0 : '1.5px solid var(--color-line)',
background: isChosen ? 'var(--color-brand)' : 'transparent',
color: '#fff',
}}
>
{isChosen ? <Check size={11} strokeWidth={3} /> : null}
</span>
<span style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span
style={{
font: '500 13.5px/1.4 var(--font-sans)',
color: isChosen ? 'var(--color-brand)' : 'var(--color-ink-1)',
}}
>
{label}
</span>
<span style={{ font: '400 12px/1.4 var(--font-sans)', color: 'var(--color-ink-3)' }}>
{detail}
</span>
</span>
</button>
);
}

View File

@@ -1,128 +0,0 @@
import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Plus } from 'lucide-react';
import type { Partner, RiderRosterRow } from '@/api/deliveries';
import { usePartnerRiders } from '@/queries/hooks';
import { Drawer } from '@/features/store-admin/Drawer';
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from '@/features/store-admin/drawerKit';
import { RiderDrawer } from '@/features/store-admin/RiderDrawer';
/**
* A delivery partner's riders, kept by the platform.
*
* ── Why the platform keeps them ─────────────────────────────────────────────
*
* A partner has no console of its own. Their riders serve whichever merchants
* the partner supplies — one partner covers 48 shops today, another 63 — so
* they sit under no single merchant and no merchant's console can manage them.
* That leaves here.
*
* ── Duty is a state, never a filter ─────────────────────────────────────────
*
* This reads the ROSTER, not `getriders`. The second wants a clock-in stamped
* today, so a rider hired five minutes ago is absent from it — which is exactly
* what a failed save looks like. Everybody is listed, and whether they are
* working right now is shown beside them.
*/
export function PartnerRidersDrawer({
partner,
onClose,
}: {
partner: Partner;
onClose: () => void;
}) {
const riders = usePartnerRiders(partner.partnerid);
const [editing, setEditing] = useState<RiderRosterRow | 'new' | null>(null);
const rows = riders.data ?? [];
const onDuty = rows.filter((rider) => rider.isonduty).length;
return (
<>
<Drawer
title={partner.partnername ?? `Partner ${partner.partnerid}`}
subtitle="Riders"
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Close" variant="ghost" onClick={onClose} />
<DrawerButton
label="Add rider"
variant="primary"
icon={<Plus size={15} />}
onClick={() => setEditing('new')}
/>
</>
}
>
<VStack gap={2}>
<Note icon={<Bike size={15} />}>
These riders deliver for every merchant this partner supplies. A rider hired here does
not appear in the on-duty fleet until they open the rider app and start a shift — that
is correct, and it looks exactly like a failed save.
</Note>
{riders.isLoading ? (
<Text type="body" size="sm" color="secondary">
Reading riders…
</Text>
) : rows.length === 0 ? (
<Section title="No riders yet">
<DrawerCard>
<Row
label="Fleet"
value="This partner has nobody on their books. Add one to get started."
isStacked
/>
</DrawerCard>
</Section>
) : (
<Section title={`${rows.length} rider${rows.length === 1 ? '' : 's'} · ${onDuty} on duty`}>
<DrawerCard>
{rows.map((rider) => (
<Row
key={rider.userid}
label={rider.fullname?.trim() || rider.firstname || `Rider ${rider.userid}`}
value={
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Badge
label={rider.isonduty ? 'On duty' : 'Off shift'}
colour={rider.isonduty ? '#0f8a5f' : 'var(--color-ink-4)'}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => setEditing(rider)}
/>
</span>
}
/>
))}
</DrawerCard>
</Section>
)}
</VStack>
</Drawer>
{editing ? (
<RiderDrawer
row={editing === 'new' ? null : editing}
/* A partner's rider, so no tenant and no branch — they work a region
and serve whoever the partner supplies in it. */
owner={{
kind: 'partner',
partnerid: partner.partnerid,
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
applocationid: partner.applocationid ?? 0,
}}
onClose={() => setEditing(null)}
/>
) : null}
</>
);
}

View File

@@ -1,91 +0,0 @@
import { useSearchParams } from 'react-router-dom';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { VStack } from '@astryxdesign/core/VStack';
import { PackageSearch, Upload } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { SheetImportPanel } from '../import/SheetImportPanel';
/**
* The platform operator's catalogue.
*
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
*
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
* where — and nothing is written. There is no merchant to choose because
* nothing lands anywhere, so the page is the rail, the search and the grid
* and nothing else. The per-product Add is gone from here: stocking one
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
* Catalogue.
*
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
* merchant's shelf. The ingest service parses, enriches and stores rows in
* the per-brand tables every merchant reads from, and it takes no tenant and
* no outlet. This mode therefore asks for nothing but a file.
*
* The grid, the filters, the cards and the detail drawer are the shared
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
* same catalogue.
*/
export function GlobalCataloguePage() {
/*
The tab is in the URL so it can be linked to.
Uploads has an "Upload spreadsheet" button and this is where that action
lives for a platform operator — it writes the GLOBAL catalogue, which is not
the same action as a merchant uploading their own list. Held in local state it
could only be reached by landing on Browse and pressing a second control,
which is a detour rather than a flow.
*/
const [params, setParams] = useSearchParams();
const mode = params.get('tab') === 'sheet' ? 'sheet' : 'catalogue';
const setMode = (next: 'catalogue' | 'sheet') => {
const merged = new URLSearchParams(params);
if (next === 'catalogue') merged.delete('tab');
else merged.set('tab', next);
setParams(merged, { replace: true });
};
return (
<VStack gap={3}>
<PageHeader
title="Global catalogue"
actions={
<SegmentedControl
label="What to do"
value={mode}
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
size="sm"
>
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
</SegmentedControl>
}
/>
{mode === 'sheet' ? (
/* The merchant and outlet pickers used to sit above this panel, and
they described a flow that no longer exists.
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
a tenant or an outlet — putting a product on one shop's shelf with a
price and opening stock is a separate call (`/api/upload/stores`,
joined on `image_id`) that is not wired up yet. Two selectors saying
the upload was "written against one merchant and one outlet" would
have had someone pick a shop, upload, and then go looking for stock
that was never going to arrive.
They come back with the inventory step, and mean something then. */
<SheetImportPanel />
) : (
<CatalogueBrowser
tenantid={undefined}
locationid={undefined}
actionLabel="Add to store"
isReadOnly
/>
)}
</VStack>
);
}

View File

@@ -1,327 +0,0 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertCircle, Building2, CheckCircle2, MapPin } from 'lucide-react';
import { tenantsApi, type CreateTenantRequest } from '@/api/tenants';
import { errorMessage } from '@/api/client';
import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
import { useAppCategories } from '@/queries/hooks';
interface FormState {
tenantname: string;
companyname: string;
/** Who runs the shop. Written to `tenants.firstname` — see `CreateTenantRequest`. */
adminname: string;
primarycontact: string;
primaryemail: string;
locationname: string;
categoryid: string;
address: string;
suburb: string;
city: string;
state: string;
postcode: string;
}
const EMPTY: FormState = {
tenantname: '',
companyname: '',
adminname: '',
primarycontact: '',
primaryemail: '',
locationname: '',
// No default category. `utils/getappcategories` supplies the real list and
// this used to pre-select the first id regardless of what the merchant sells.
categoryid: '',
address: '',
suburb: '',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
};
/**
* Provision a new merchant tenant.
*
* This registers the enterprise, its first outlet, and spawns the primary
* Administrator account — account creation is a side effect of provisioning
* here, because that is what the backend does. There is no separate invite.
*
* Products are NOT collected on this form. Once the tenant exists it has a
* tenantid and a locationid, and only then can either import path run: both
* `importcatalogueproduct` and `createproductlocation` require the pair. So the
* form hands off to the catalogue on success rather than pretending the two
* steps are one.
*/
export function OnboardTenantPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<FormState>(EMPTY);
/**
* The category list comes from `app_category`, not from four values typed
* into this file. A category added to the master should appear here without
* a release.
*/
const categories = useAppCategories();
const [error, setError] = useState<string | null>(null);
function set<K extends keyof FormState>(key: K) {
return (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
}
const mutation = useMutation({
mutationFn: (body: CreateTenantRequest) => tenantsApi.createTenant(body),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
},
onError: (cause) => setError(errorMessage(cause)),
});
const isComplete =
form.tenantname.trim() !== '' &&
form.companyname.trim() !== '' &&
form.adminname.trim() !== '' &&
form.primarycontact.trim() !== '' &&
form.primaryemail.trim() !== '' &&
form.locationname.trim() !== '' &&
form.address.trim() !== '' &&
form.city.trim() !== '' &&
form.state.trim() !== '' &&
form.categoryid !== '' &&
form.postcode.trim() !== '';
function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
mutation.mutate({
tenantname: form.tenantname.trim(),
companyname: form.companyname.trim(),
firstname: form.adminname.trim(),
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
locationname: form.locationname.trim(),
// `Number('')` is NaN, which serialises to null and is not what the
// backend means by "uncategorised" — 0 is.
categoryid: Number(form.categoryid) || 0,
address: form.address.trim(),
suburb: form.suburb.trim(),
city: form.city.trim(),
state: form.state.trim(),
postcode: form.postcode.trim(),
status: 'Active',
});
}
if (mutation.isSuccess) {
const created = mutation.data;
return (
<VStack gap={3}>
<PageHeader
title="Tenant provisioned"
/>
<Card padding={4} elevation="low">
<VStack gap={3}>
<HStack align="center" gap={1.5}>
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
<Text type="large" weight="semibold">
{form.tenantname} is live
</Text>
</HStack>
<Text type="body" color="secondary">
Its first outlet, {form.locationname}, has been commissioned. The next step is
stocking the catalogue — pick products from the global catalogue, or upload the
tenant&apos;s own list as a spreadsheet.
</Text>
{/* The storefront code, at the one moment the person who provisioned
the shop is holding its details.
It was reachable only from the branch user's own header, which
is the wrong place for it: the code is what puts the store in
front of a shopper at all — nobody can order from a shop they
have not scanned — and the person onboarding it is the one who
sends it to the merchant. `createtenantuser` returns the tenant
and its primary outlet's id together, so it can be drawn here
without a second read. */}
{created?.tenantid && created?.locationid ? (
<Card padding={3} elevation="low" variant="transparent">
<StoreQrPanel
tenantid={created.tenantid}
locationid={created.locationid}
locationname={form.locationname}
where={[form.suburb, form.city].filter(Boolean).join(', ')}
/>
</Card>
) : null}
<HStack gap={1.5} wrap="wrap">
<Button
label="Stock from global catalogue"
variant="primary"
onClick={() =>
navigate(
`/nearle/catalogue?tenantid=${created?.tenantid ?? ''}&locationid=${created?.locationid ?? ''}`,
)
}
/>
<Button
label="Back to stores"
variant="secondary"
onClick={() => navigate('/nearle/stores')}
/>
</HStack>
</VStack>
</Card>
</VStack>
);
}
return (
<PageBody measure="reading">
<PageHeader
title="Onboard tenant"
/>
<form onSubmit={handleSubmit}>
<VStack gap={3}>
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Business"
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div className="form-grid">
<TextInput
label={<span>Merchant name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.tenantname}
onChange={set('tenantname')}
placeholder="e.g. Kaveri Groceries"
/>
<TextInput
label={<span>Company registered name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.companyname}
onChange={set('companyname')}
placeholder="e.g. Kaveri Retail Pvt. Ltd."
/>
{/* Asked here because here is the only place it can be asked.
The primary outlet and the merchant's own admin login are
both created inside this one call, and the login is copied
from the tenant row — so the name given here names the
business AND the account that signs in. Left out, both are
blank, which is the state every merchant is in today. */}
<TextInput
label={<span>Store admin <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.adminname}
onChange={set('adminname')}
placeholder="e.g. Ravi Kumar"
description="The person who administers this shop. Their name goes on the store profile and on the admin login created with it — the account is provisioned as Admin, which is what this field is named after."
/>
<TextInput
label={<span>Primary phone <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
/>
<TextInput
label={<span>Primary admin email <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
type="email"
value={form.primaryemail}
onChange={set('primaryemail')}
placeholder="admin@kaveri.com"
/>
<TextInput
label={<span>First outlet name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.locationname}
onChange={set('locationname')}
placeholder="e.g. Kaveri RS Puram"
/>
<Selector
label="Business category"
options={(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
}))}
isDisabled={categories.isLoading}
value={form.categoryid}
onChange={set('categoryid')}
/>
</div>
</VStack>
</Card>
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader
title="Head office"
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<TextInput
label={<span>Street address <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.address}
onChange={set('address')}
placeholder="e.g. 12, Avinashi Road"
/>
<div className="form-grid-4">
<TextInput label="Suburb" value={form.suburb} onChange={set('suburb')} placeholder="e.g. Peelamedu" />
<TextInput label={<span>City <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.city} onChange={set('city')} />
<TextInput label={<span>State <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={form.state} onChange={set('state')} />
<TextInput
label={<span>Postcode <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
value={form.postcode}
onChange={set('postcode')}
placeholder="641004"
/>
</div>
</VStack>
</Card>
{error ? (
<HStack
align="center"
gap={1}
padding={2}
style={{
background: 'var(--color-error-muted, #fceeee)',
borderRadius: 12,
color: 'var(--color-error, #d64545)',
}}
>
<AlertCircle size={17} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
{error}
</Text>
</HStack>
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
type="submit"
variant="primary"
size="lg"
isLoading={mutation.isPending}
isDisabled={!isComplete}
/>
</HStack>
</VStack>
</form>
</PageBody>
);
}

View File

@@ -1,612 +0,0 @@
/**
* Rider partners — the companies that supply riders.
*
* ── Why this page did not exist ─────────────────────────────────────────────
*
* `getpartners` has always been readable and nothing on the platform could
* create a partner, so the five that exist were inserted by hand — two are
* still called "Test". Meanwhile 125 of 200 merchants already carry a
* `partnerid`, and one partner supplies 48 shops while another supplies 63. The
* relationship the whole delivery side rests on was real, live and unmanaged.
*
* ── What onboarding a partner records ───────────────────────────────────────
*
* Three things, and the last two are why the assign screen works at all:
*
* the district `partnerinfo.applocationid`, and `partnerlocations` beside
* it. Every rider query joins through that id, so it has to be
* a district Nearle actually services — see
* `tamilNaduDistricts.ts` for why all 38 are shown anyway.
* the merchant `tenants.partnerid`. This is what the assign screen reads to
* decide whether to offer a partner tab at all.
* the branch `tenantlocations.partnerid`. Which outlet they cover.
*
* A partner can also be attached to a merchant afterwards from that merchant's
* own page — see `StoreDetailPage` — which is the ordinary case of a shop
* changing partner without anybody re-onboarding the company.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, Plus, Truck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { partnersApi, type NewPartner, type Partner } from '@/api/deliveries';
import { tenantsApi } from '@/api/tenants';
import type { TenantInfo } from '@/api/types';
import { Selector } from '@astryxdesign/core/Selector';
import { districtOptions, isRunning, matchDistrict } from '../tamilNaduDistricts';
import { DataState } from '@/components/DataState';
import { PageHeader } from '@/components/PageHeader';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import {
useAllPartners,
useAppRegions,
usePartnerRiderCounts,
useTenantLocations,
useTenants,
} from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { Drawer } from '@/features/store-admin/Drawer';
import { PartnerRidersDrawer } from '../PartnerRidersDrawer';
import { DrawerButton } from '@/features/store-admin/drawerKit';
interface PartnerRow extends Record<string, unknown> {
partnerid: number;
partnername: string;
companyname: string;
region: string;
contact: string;
status: string;
/** How many riders they have. `null` while the count is still being read. */
riders: number | null;
}
export function PartnersPage() {
const partners = useAllPartners();
const regions = useAppRegions();
const [editing, setEditing] = useState<Partner | 'new' | null>(null);
/** The partner whose riders are on screen, if any. */
const [ridersFor, setRidersFor] = useState<Partner | null>(null);
/* The fleet size per partner, read alongside the directory. Without it the
Riders button is a door with nothing written on it. */
const riderCounts = usePartnerRiderCounts(partners.data.map((entry) => entry.partnerid));
const regionName = useMemo(() => {
const map = new Map<number, string>();
for (const region of regions.data ?? []) {
map.set(region.applocationid, region.locationname ?? `Region ${region.applocationid}`);
}
return map;
}, [regions.data]);
const rows = useMemo<PartnerRow[]>(
() =>
partners.data.map((partner) => ({
partnerid: partner.partnerid,
partnername: partner.partnername ?? `Partner ${partner.partnerid}`,
companyname: partner.companyname ?? '',
region: regionName.get(partner.applocationid ?? 0) ?? '—',
contact: partner.primarycontact || partner.contactno || '',
status: partner.status || 'Active',
riders: riderCounts.get(partner.partnerid) ?? null,
})),
[partners.data, regionName, riderCounts],
);
const paged = usePaged(rows);
const columns: TableColumn<PartnerRow>[] = [
{
key: 'partnername',
header: 'Partner',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.partnername}
</Text>
{row.companyname ? (
<Text type="body" size="xsm" color="secondary">
{row.companyname}
</Text>
) : null}
</VStack>
),
},
{
key: 'region',
header: 'Home region',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.region}
</Text>
),
},
{
key: 'contact',
header: 'Contact',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="sm" style={{ fontFamily: 'var(--font-mono)' }}>
{row.contact || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
/* Both actions in ONE column with a header, rather than two unlabelled
ones. The riders button carries the fleet size, because "Riders" alone
asks you to open a drawer to learn whether there are any — and the
answer is the reason you would open it. */
key: 'actions',
header: 'Fleet',
align: 'end',
width: { type: 'pixel', value: 184 },
renderCell: (row) => (
<HStack gap={0.5} justify="end" align="center" className="fleet-actions">
<Button
/* One width for every row, fixed in CSS. The label is a count, so
it runs from one digit to three and a shrink-to-fit button leaves
the column ragged — an uneven edge reads as disorder before the
numbers themselves are read. */
label={row.riders === null ? '—' : `${row.riders} rider${row.riders === 1 ? '' : 's'}`}
variant="secondary"
size="sm"
icon={<Bike size={13} />}
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setRidersFor(found);
}}
/>
<Button
label="Edit"
variant="ghost"
size="sm"
onClick={() => {
const found = partners.data.find((p) => p.partnerid === row.partnerid);
if (found) setEditing(found);
}}
/>
</HStack>
),
},
];
return (
<VStack gap={3}>
<PageHeader
title="Rider partners"
actions={
<Button
label="Onboard rider partner"
variant="primary"
size="sm"
icon={<Plus size={14} />}
onClick={() => setEditing('new')}
/>
}
/>
<VStack gap={1.5}>
{/* No section heading. The page is already titled "Rider partners" and
the table is the only thing on it — a second heading over one table
restates the page and pushes the rows down a row for nothing. */}
<Card padding={0} elevation="low">
<DataState
isLoading={partners.isLoading}
error={null}
isEmpty={rows.length === 0}
emptyTitle="No rider partners yet"
emptyDescription="A rider partner is the company that supplies riders. Onboard one, choose its district, and attach it to the shop and branch it delivers for."
>
<div className="table-scroll">
<Table<PartnerRow>
data={paged.rows}
columns={columns}
idKey="partnerid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="partners" />
</DataState>
</Card>
</VStack>
{ridersFor ? (
<PartnerRidersDrawer partner={ridersFor} onClose={() => setRidersFor(null)} />
) : null}
{editing ? (
<PartnerDrawer
partner={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
/>
) : null}
</VStack>
);
}
/* ── The form ─────────────────────────────────────────────────────────────── */
interface FormState {
partnername: string;
companyname: string;
registrationno: string;
primarycontact: string;
primaryemail: string;
address: string;
suburb: string;
city: string;
state: string;
postcode: string;
/** The serviced district they work out of — an `app_location` id. */
applocationid: number;
/** The merchant this partner delivers for. */
tenantid: number;
/** Which branch of that merchant — written to `tenantlocations.partnerid`. */
locationid: number;
}
const EMPTY: FormState = {
partnername: '',
companyname: '',
registrationno: '',
primarycontact: '',
primaryemail: '',
address: '',
suburb: '',
city: '',
state: '',
postcode: '',
applocationid: 0,
tenantid: 0,
locationid: 0,
};
function PartnerDrawer({ partner, onClose }: { partner: Partner | null; onClose: () => void }) {
const client = useQueryClient();
const regions = useAppRegions();
const isNew = partner === null;
const [form, setForm] = useState<FormState>(() =>
partner
? {
partnername: partner.partnername ?? '',
companyname: partner.companyname ?? '',
registrationno: partner.registrationno ?? '',
primarycontact: partner.primarycontact ?? partner.contactno ?? '',
primaryemail: partner.primaryemail ?? '',
address: partner.address ?? '',
suburb: partner.suburb ?? '',
city: partner.city ?? '',
state: partner.state ?? '',
postcode: '',
applocationid: partner.applocationid ?? 0,
tenantid: 0,
locationid: 0,
}
: EMPTY,
);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof FormState>(key: K) {
return (value: FormState[K]) => {
setError(null);
setForm((prev) => ({ ...prev, [key]: value }));
};
}
/* ── The district ─────────────────────────────────────────────────────────
One per partner, chosen from all 38. Picking one Nearle does not run yet
opens it — the partner is sent with the NAME and the server writes the
`app_location` and `app_locationconfig` rows first. */
const [districtSearch, setDistrictSearch] = useState('');
const [district, setDistrict] = useState<string>(() => partner?.city ?? '');
const districts = useMemo(() => districtOptions(regions.data ?? []), [regions.data]);
const shown = useMemo(
() => districts.filter((option) => matchDistrict(option, districtSearch)),
[districts, districtSearch],
);
const chosenDistrict = districts.find((option) => option.name === district);
/* ── Who they deliver for ─────────────────────────────────────────────────
A partner supplies riders TO a merchant's branch. Both links are written:
`tenants.partnerid`, which is what the assign screen reads to decide
whether to offer a partner tab at all, and `tenantlocations.partnerid`,
which records the branch. Without the first the toggle never appears;
without the second nothing says which outlet they cover.
Filtered to the district: a partner works one district, so a merchant in
another is not somebody they can deliver for. `getalltenants` returns a row
per BRANCH, and a branch's city is what places it — the tenant's own city
is the head office and can differ. */
const merchants = useTenants({ pageno: 1, pagesize: 200 });
const merchantOptions = useMemo(() => {
const here = district.trim().toLowerCase();
const seen = new Map<number, string>();
for (const entry of (merchants.data ?? []) as TenantInfo[]) {
if (!entry.tenantid || seen.has(entry.tenantid)) continue;
const city = (entry.city ?? '').trim().toLowerCase();
if (here && city && city !== here) continue;
seen.set(entry.tenantid, entry.tenantname);
}
return [...seen.entries()].map(([value, label]) => ({ value: String(value), label }));
}, [merchants.data, district]);
const branches = useTenantLocations(form.tenantid || undefined);
const branchOptions = useMemo(
() =>
(branches.data ?? []).map((branch) => ({
value: String(branch.locationid),
label: branch.locationname || `Branch ${branch.locationid}`,
})),
[branches.data],
);
const save = useMutation({
mutationFn: () => {
const body: NewPartner = {
partnername: form.partnername.trim(),
companyname: form.companyname.trim(),
registrationno: form.registrationno.trim(),
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
address: form.address.trim(),
suburb: form.suburb.trim(),
city: form.city.trim(),
state: form.state.trim(),
...(form.postcode.trim() ? { postcode: Number(form.postcode) || 0 } : {}),
/*
The district, by id when Nearle already runs it and by NAME when it
does not. The name is what opens it — the server writes the region
rows before the partner, so all 38 are real choices rather than three.
*/
applocationid: chosenDistrict?.applocationid ?? 0,
...(chosenDistrict && chosenDistrict.applocationid === 0
? { district: chosenDistrict.name }
: {}),
};
return partner
? partnersApi.update({ ...body, partnerid: partner.partnerid }).then(() => partner.partnerid)
: partnersApi.create(body).then((result) => result?.partnerid ?? 0);
},
/*
The placement is written after the partner exists, because it needs the
id the create hands back.
Reported separately if it fails, and deliberately not rolled back: the
partner is real either way and re-onboarding them would refuse on the
duplicate contact number. Saying "the partner was created but could not be
placed" is recoverable — the drawer stays open on the same form.
*/
onSuccess: async (partnerid) => {
if (partnerid > 0 && form.tenantid > 0) {
try {
await tenantsApi.assignPartner(form.tenantid, partnerid);
if (form.locationid > 0) {
await tenantsApi.updateBranch({ locationid: form.locationid, partnerid });
}
} catch (cause) {
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
setError(
`${form.partnername.trim()} was saved, but could not be assigned to that branch: ${errorMessage(cause)}`,
);
return;
}
}
await client.invalidateQueries({ queryKey: queryKeys.partners.all });
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
onClose();
},
onError: (cause) => setError(errorMessage(cause)),
});
const isComplete =
form.partnername.trim() !== '' && form.primarycontact.trim() !== '' && form.applocationid > 0;
return (
<Drawer
title={isNew ? 'Onboard a rider partner' : form.partnername || 'Rider partner'}
subtitle={isNew ? 'The company that supplies riders' : `Partner ${partner?.partnerid}`}
width={520}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton label="Cancel" variant="ghost" onClick={onClose} />
<DrawerButton
label={save.isPending ? 'Saving…' : isNew ? 'Onboard rider partner' : 'Save changes'}
variant="primary"
icon={<Truck size={15} />}
isDisabled={!isComplete || save.isPending}
onClick={() => save.mutate()}
/>
</>
}
>
<VStack gap={2}>
{error ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
{error}
</Text>
) : null}
<TextInput
label="Partner name"
size="sm"
value={form.partnername}
onChange={set('partnername')}
placeholder="e.g. Xpress-Cbe-Main"
description="How the partner is named on rider records and in the assign picker."
/>
<TextInput
label="Registered company"
size="sm"
value={form.companyname}
onChange={set('companyname')}
/>
<TextInput
label="Primary contact"
size="sm"
value={form.primarycontact}
onChange={set('primarycontact')}
placeholder="9876543210"
description="One partner per number — the server refuses a second."
/>
<TextInput
label="Email"
size="sm"
value={form.primaryemail}
onChange={set('primaryemail')}
/>
<TextInput
label="Registration number"
size="sm"
value={form.registrationno}
onChange={set('registrationno')}
/>
{/* ── District ──────────────────────────────────────────────────────
All 38 of Tamil Nadu's districts, searchable, with only the ones
Nearle services selectable. A partner placed in a district that has
no `app_location` row is a partner whose riders no query returns —
`getriders` filters on that id — so an unserviced district is shown
and refused rather than hidden, because "Erode is not open yet" is
an answer and a missing Erode is not. */}
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
District
</Text>
<TextInput
label="Search districts"
isLabelHidden
size="sm"
value={districtSearch}
onChange={setDistrictSearch}
placeholder="Search all 38 districts…"
hasClear
/>
{/* A single-select list, not a cloud of chips: one partner works one
district, so this is a choice with one answer and it should read
like one. Running districts carry a tick, new ones say what will
happen — the difference is operational, not a restriction. */}
<div className="district-list" role="listbox" aria-label="Tamil Nadu districts">
{shown.map((option) => {
const running = isRunning(option);
const chosen = district === option.name;
return (
<button
key={option.name}
type="button"
role="option"
aria-selected={chosen}
className="district-row"
data-chosen={chosen ? 'yes' : 'no'}
onClick={() => {
setDistrict(option.name);
// The merchant is district-scoped, so changing the district
// invalidates it — keeping it would attach a partner to a
// shop in a place they do not work.
setForm((prev) => ({ ...prev, tenantid: 0, locationid: 0 }));
}}
>
<span className="district-row-name">{option.name}</span>
<span className="district-row-state" data-running={running ? 'yes' : 'no'}>
{running ? 'Running' : 'New district'}
</span>
</button>
);
})}
{shown.length === 0 ? (
<Text type="body" size="sm" color="secondary" style={{ padding: '10px 12px' }}>
No district matches “{districtSearch}”.
</Text>
) : null}
</div>
<Text type="body" size="xsm" color="secondary">
{chosenDistrict
? isRunning(chosenDistrict)
? `Riders are listed against ${chosenDistrict.name}.`
: `${chosenDistrict.name} will be opened when this partner is saved.`
: 'One district per partner. Choosing one Nearle does not run yet opens it.'}
</Text>
</VStack>
{/* ── Who they deliver for ──────────────────────────────────────────
The merchant, then the branch. Both links are written: the merchant
one is what the assign screen reads to decide whether to offer a
partner tab at all, and the branch one records which outlet. */}
<VStack gap={1}>
<Text type="label" size="sm" weight="semibold">
Delivers for
</Text>
<Selector
label="Merchant"
size="sm"
value={form.tenantid ? String(form.tenantid) : ''}
onChange={(value) => {
// A new merchant clears the branch with it — keeping it would
// leave another shop's outlet id attached to this partner.
setForm((prev) => ({ ...prev, tenantid: Number(value) || 0, locationid: 0 }));
}}
options={merchantOptions}
placeholder={merchants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
/>
<Selector
label="Branch"
size="sm"
value={form.locationid ? String(form.locationid) : ''}
onChange={(value) => set('locationid')(Number(value) || 0)}
options={branchOptions}
isDisabled={!form.tenantid}
placeholder={
!form.tenantid
? 'Choose a merchant first'
: branches.isLoading
? 'Loading branches…'
: 'Choose the branch they cover'
}
/>
<Text type="body" size="xsm" color="secondary">
Optional. Set it and this partner’s riders become an option on that shop’s assign
screen, beside any riders it hired itself.
</Text>
</VStack>
<TextInput label="Address" size="sm" value={form.address} onChange={set('address')} />
<HStack gap={1}>
<TextInput label="Area" size="sm" value={form.suburb} onChange={set('suburb')} />
<TextInput label="City" size="sm" value={form.city} onChange={set('city')} />
</HStack>
<HStack gap={1}>
<TextInput label="State" size="sm" value={form.state} onChange={set('state')} />
<TextInput label="Postcode" size="sm" value={form.postcode} onChange={set('postcode')} />
</HStack>
</VStack>
</Drawer>
);
}

View File

@@ -1,331 +0,0 @@
import { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { IndianRupee, QrCode, ShoppingCart, Store, TriangleAlert, Truck } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationSummary, useOrderSummary, useTenantLocations, useTenants } from '@/queries/hooks';
import type { LocationOrderSummary, TenantInfo, TenantLocation } from '@/api/types';
import { TablePager } from '@/components/TablePager';
import { usePaged } from '@/components/usePaged';
import { Drawer } from '@/features/store-admin/Drawer';
import { StoreQrPanel } from '@/features/qr/StoreQrPanel';
import { PartnerAssignDrawer } from '../PartnerAssignDrawer';
interface BranchRow extends Record<string, unknown> {
locationid: number;
locationname: string;
city: string;
hours: string;
radius: string;
status: string;
orders: number;
revenue: number;
}
function money(value: number | undefined): string {
if (typeof value !== 'number' || Number.isNaN(value)) return '₹0';
return `₹${value.toLocaleString('en-IN')}`;
}
/**
* One tenant: its branches, and how each is performing.
*
* Order figures come from `/orders/getlocationsummary`, which is scoped to the
* tenant and returns one row per branch. Counter sales are NOT folded in here:
* the POS endpoints take a single required locationid, so a tenant-wide till
* figure would mean one request per branch, and a blended number would be
* eventually consistent in a way this page cannot honestly caption. Branch-level
* POS lives on the branch screen instead.
*/
export function StoreDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const tenantid = Number(tenantId ?? 0) || undefined;
const { data: tenants } = useTenants();
const { data: locations, isLoading, error } = useTenantLocations(tenantid);
const summary = useLocationSummary(tenantid);
const orders = useOrderSummary(tenantid);
const tenant = useMemo(
() => (tenants as TenantInfo[] | undefined)?.find((entry) => entry.tenantid === tenantid),
[tenants, tenantid],
);
const rows = useMemo<BranchRow[]>(() => {
const perLocation = new Map<number, LocationOrderSummary>();
for (const entry of summary.data ?? []) {
if (typeof entry.locationid === 'number') perLocation.set(entry.locationid, entry);
}
return ((locations ?? []) as TenantLocation[]).map((branch) => {
const stats = perLocation.get(branch.locationid);
return {
locationid: branch.locationid,
locationname: branch.locationname,
city: [branch.suburb, branch.city].filter(Boolean).join(', '),
hours:
branch.opentime && branch.closetime ? `${branch.opentime}–${branch.closetime}` : '—',
radius: branch.deliveryradius ? `${(branch.deliveryradius / 1000).toFixed(1)} km` : '—',
status: branch.status ?? 'Unknown',
orders: Number(stats?.totalorders ?? 0),
revenue: Number(stats?.revenue ?? 0),
};
});
}, [locations, summary.data]);
const paged = usePaged(rows);
const totals = useMemo(() => {
const branches = rows.length;
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
const totalOrders = rows.reduce((sum, row) => sum + row.orders, 0);
const totalRevenue = rows.reduce((sum, row) => sum + row.revenue, 0);
const cancelled = Number(orders.data?.cancelled ?? 0);
return { branches, active, totalOrders, totalRevenue, cancelled };
}, [rows, orders.data]);
/** The branch whose code is on screen, if any. */
const [qrFor, setQrFor] = useState<BranchRow | null>(null);
/** Open while the merchant's delivery partner is being changed. */
const [isPartnerOpen, setPartnerOpen] = useState(false);
const columns: TableColumn<BranchRow>[] = [
{
key: 'locationname',
header: 'Branch',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.locationname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.city || '—'}
</Text>
</VStack>
),
},
{
key: 'hours',
header: 'Hours',
width: { type: 'pixel', value: 130 },
renderCell: (row) => (
<Text type="body" size="sm" color="secondary">
{row.hours}
</Text>
),
},
{
key: 'radius',
header: 'Radius',
align: 'end',
width: { type: 'pixel', value: 90 },
renderCell: (row) => (
<Text type="body" size="sm" hasTabularNumbers>
{row.radius}
</Text>
),
},
{
key: 'orders',
header: 'Orders',
align: 'end',
width: { type: 'pixel', value: 100 },
renderCell: (row) => (
<Text
type="label"
size="sm"
hasTabularNumbers
color={row.orders === 0 ? 'disabled' : 'primary'}
>
{row.orders}
</Text>
),
},
{
key: 'revenue',
header: 'Revenue',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Text
type="label"
size="sm"
hasTabularNumbers
color={row.revenue === 0 ? 'disabled' : 'primary'}
>
{money(row.revenue)}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
/* The storefront code, per branch, after the day it was commissioned.
Shown at creation too — but a poster gets lost, a shop reopens, a
merchant asks for it again a month later, and the person they ask is
whoever is looking at this page. The code is derived from the two ids
on this row, so there is nothing to look up and nothing to reissue. */
key: 'qr',
header: 'QR',
align: 'end',
width: { type: 'pixel', value: 84 },
renderCell: (row) => (
<Button
label="QR"
variant="ghost"
size="sm"
icon={<QrCode size={14} />}
onClick={() => setQrFor(row)}
/>
),
},
];
return (
<VStack gap={3}>
<PageHeader
title={tenant?.tenantname ?? 'Tenant'}
actions={
<HStack gap={1}>
<Button
label="Delivery partner"
variant="secondary"
icon={<Truck size={14} />}
onClick={() => setPartnerOpen(true)}
/>
<Button
label="Stock catalogue"
variant="secondary"
href={`/nearle/catalogue?tenantid=${tenantid ?? ''}`}
as={Link}
/>
</HStack>
}
/>
<div className="kpi-grid">
<KpiCard
label="Branches"
value={String(totals.branches)}
note={`${totals.active} active`}
tone="accent"
icon={<Store size={15} />}
fill={totals.branches ? totals.active / totals.branches : 0}
/>
<KpiCard
label="Orders"
value={String(totals.totalOrders)}
note="all statuses"
tone="neutral"
icon={<ShoppingCart size={15} />}
/>
<KpiCard
label="Revenue"
value={money(totals.totalRevenue)}
note="from order summary"
tone={totals.totalRevenue > 0 ? 'success' : 'neutral'}
icon={<IndianRupee size={15} />}
/>
<KpiCard
label="Cancelled"
value={String(totals.cancelled)}
note={
totals.totalOrders
? `${Math.round((totals.cancelled / totals.totalOrders) * 100)}% of orders`
: 'no orders yet'
}
tone={totals.cancelled > 0 ? 'error' : 'neutral'}
icon={<TriangleAlert size={15} />}
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
/>
{/* No "Catalogue — Linked" tile.
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
tenant row came back at all — it described the tenant existing, not
the catalogue. `products/getimportedcatalogueproducts` could give a
real imported count here; until something calls it, a tile that is
always green is worse than no tile. */}
</div>
<VStack gap={1.5}>
<SectionHeader
title="Branches"
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
/>
<Card padding={0} elevation="low">
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle="No branches yet"
/* No CTA here: this tenant's own Administrator commissions their
outlets. Naming who acts is more useful than a button that would
take an action out of their hands. */
emptyDescription="This tenant's Administrator opens their outlets from their own workspace."
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<BranchRow>
data={paged.rows}
columns={columns}
idKey="locationid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
<TablePager paged={paged} label="branches" />
</DataState>
</Card>
</VStack>
{isPartnerOpen && tenantid ? (
<PartnerAssignDrawer
tenantid={tenantid}
tenantname={tenant?.tenantname ?? 'this merchant'}
currentPartnerId={Number((tenant as unknown as Record<string, number>)?.['partnerid'] ?? 0)}
onClose={() => setPartnerOpen(false)}
/>
) : null}
{qrFor && tenantid ? (
<Drawer
title="Store QR code"
subtitle={qrFor.locationname}
width={400}
onClose={() => setQrFor(null)}
>
<StoreQrPanel
tenantid={tenantid}
locationid={qrFor.locationid}
locationname={qrFor.locationname}
{...(qrFor.city ? { where: String(qrFor.city) } : {})}
/>
</Drawer>
) : null}
</VStack>
);
}

View File

@@ -1,489 +0,0 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Building2, Plus, Store, Users } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
/** A tenant, with its branches folded in. */
interface TenantRow extends Record<string, unknown> {
tenantid: number;
tenantname: string;
companyname: string;
city: string;
branches: number;
status: string;
primaryemail: string;
}
/**
* The Nearle Admin's home: every tenant on the platform, and how many branches
* sit under each.
*
* `getalltenants` returns one row per tenant-location pair, so the rows are
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
* branches reads as six tenants.
*/
type Tab = 'directory' | 'pending';
/** Rows per page. One more than this is fetched, to know whether there is a next. */
const PAGE_SIZE = 50;
export function StoresPage() {
const [tab, setTab] = useState<Tab>('directory');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
/**
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
* and has no total, so paging is "ask for one more than we show and see if it
* comes back". A platform list read whole is fine at twenty tenants and not
* at two thousand.
*/
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
/**
* The queue of merchants awaiting approval.
*
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
* `getalltenants` at all, so without this they are invisible. Nothing here
* can approve one — `approved` is writable only at creation — so this lists
* and says so.
*/
const pending = useTenantsByApproval('pending');
const rows = useMemo<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
const existing = grouped.get(tenant.tenantid);
if (existing) {
existing.branches += 1;
continue;
}
grouped.set(tenant.tenantid, {
tenantid: tenant.tenantid,
tenantname: tenant.tenantname,
companyname: tenant.companyname ?? '',
city: tenant.city ?? '',
branches: 1,
status: tenant.status ?? 'Unknown',
primaryemail: tenant.primaryemail ?? '',
});
}
const all = [...grouped.values()];
const term = search.trim().toLowerCase();
if (!term) return all;
return all.filter(
(row) =>
row.tenantname.toLowerCase().includes(term) ||
row.companyname.toLowerCase().includes(term) ||
row.city.toLowerCase().includes(term),
);
}, [data, search]);
const totals = useMemo(() => {
const tenants = rows.length;
const branches = rows.reduce((sum, row) => sum + row.branches, 0);
const active = rows.filter((row) => row.status.toLowerCase() === 'active').length;
return { tenants, branches, active };
}, [rows]);
const columns: TableColumn<TenantRow>[] = [
{
key: 'tenantname',
header: 'Tenant',
width: { type: 'proportional', value: 3 },
renderCell: (row) => (
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{row.tenantname}
</Text>
<Text type="body" size="xsm" color="secondary">
{row.companyname || '—'}
</Text>
</VStack>
),
},
{
key: 'city',
header: 'City',
width: { type: 'proportional', value: 1.5 },
renderCell: (row) => <Text type="body" size="sm">{row.city || '—'}</Text>,
},
{
key: 'branches',
header: 'Branches',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Text type="label" size="sm" hasTabularNumbers>
{row.branches}
</Text>
),
},
{
key: 'primaryemail',
header: 'Primary admin',
width: { type: 'proportional', value: 2 },
renderCell: (row) => (
<Text type="body" size="xsm" color="secondary">
{row.primaryemail || '—'}
</Text>
),
},
{
key: 'status',
header: 'Status',
align: 'end',
width: { type: 'pixel', value: 120 },
renderCell: (row) => (
<Badge
variant={row.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={row.status}
/>
),
},
{
key: 'actions',
header: '',
align: 'end',
width: { type: 'pixel', value: 110 },
renderCell: (row) => (
<Link
to={`/nearle/stores/${row.tenantid}`}
style={{
color: 'var(--color-brand)',
fontWeight: 600,
fontSize: 13,
textDecoration: 'none',
}}
>
Open →
</Link>
),
},
];
return (
<VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */}
<PageHeader
title="Stores"
actions={
<Button
label="Onboard tenant"
variant="primary"
icon={<Plus size={15} />}
href="/nearle/onboard/tenant"
as={Link}
/>
}
/>
<div className="kpi-grid">
<KpiCard
label="Tenants"
value={String(totals.tenants)}
note={`${totals.active} active`}
tone="accent"
icon={<Building2 size={15} />}
fill={totals.tenants ? totals.active / totals.tenants : 0}
/>
<KpiCard
label="Branches"
value={String(totals.branches)}
note="across the network"
tone="neutral"
icon={<Store size={15} />}
/>
<KpiCard
label="Avg branches"
value={totals.tenants ? (totals.branches / totals.tenants).toFixed(1) : '0'}
note="per tenant"
tone="neutral"
icon={<Users size={15} />}
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Tenant directory"
note={`${rows.length} shown`}
action={
<div className="toolbar-stack" style={{ maxWidth: 260 }}>
<TextInput
label="Search tenants"
isLabelHidden
value={search}
onChange={setSearch}
placeholder="Search by name, company or city…"
hasClear
size="sm"
/>
</div>
}
/>
<Card padding={0} elevation="low">
<DataState
isLoading={isLoading}
error={error}
isEmpty={rows.length === 0}
emptyTitle={search ? 'No tenants match that search' : 'No tenants yet'}
emptyDescription={
search
? 'Try a different name, company or city.'
: 'Onboard the first merchant group to get started.'
}
emptyAction={
search ? undefined : (
<Button label="Onboard tenant" variant="primary" href="/nearle/onboard/tenant" as={Link} />
)
}
>
{/* Columns carry meaning, so the table scrolls sideways rather
than dropping any of them. The page itself never scrolls wide. */}
<div className="table-scroll">
<Table<TenantRow>
data={rows}
columns={columns}
idKey="tenantid"
density="balanced"
hasHover
dividers="rows"
/>
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* Read-only, and it says why on the panel rather than offering a button that
* cannot work: `tenants.approved` is written once, at creation, and there is no
* update or approve endpoint anywhere in the backend. Until there is, this is
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
}}
>
{children}
</td>
);

View File

@@ -1,39 +0,0 @@
import { useDateScope } from '@/components/shell/DateScope';
import { useNavigate } from 'react-router-dom';
import { VStack } from '@astryxdesign/core/VStack';
import { PageHeader } from '@/components/PageHeader';
import { UploadsPanel } from '@/features/uploads/UploadsPanel';
/**
* Every spreadsheet on the platform, whoever sent it.
*
* No tenant scope, which is the whole difference from the two store versions:
* the Nearle Admin is who chases the catalogue team when a drop sits unreviewed,
* and they cannot do that from one merchant at a time. The panel shows the
* merchant name on each row when it is not given one.
*/
export function UploadsPage() {
const navigate = useNavigate();
/*
No date filter by default, unlike the reporting pages.
This is a receipt log, and the reason somebody opens it is to find an upload
from a while ago. Starting at month-to-date hid every receipt older than the
1st — the list read "Nothing matches" while the tab beside it said there were
two. The read already returns only the most recent fifty, so the range is a
narrowing rather than the thing that makes the page tractable.
*/
const dates = useDateScope();
return (
<VStack gap={3}>
<PageHeader
title="Uploads"
/>
{/* A platform operator's upload writes the GLOBAL catalogue, which is a
different action from a merchant sending their own list — so this goes
to the page that owns it rather than opening a merchant's drawer. */}
<UploadsPanel range={dates.range} onClearRange={dates.clear} onUpload={() => navigate('/nearle/catalogue?tab=sheet')} />
</VStack>
);
}

View File

@@ -1,74 +0,0 @@
/**
* The district list, and the line between "not open yet" and "not a place".
*
* A partner placed in a district with no `app_location` row is a partner whose
* riders no query returns — `getriders` filters on that id and `CreateRider`
* refuses a region with no config. Every district can be chosen — picking one
* that is not running yet OPENS it, writing both rows server-side — so what
* these pin is that the list is complete and that the running ones carry the id
* they will be saved with.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
TAMIL_NADU_DISTRICTS,
districtOptions,
isRunning,
matchDistrict,
} from './tamilNaduDistricts';
const live = [
{ applocationid: 1, locationname: 'Coimbatore' },
{ applocationid: 2, locationname: 'Madurai' },
{ applocationid: 23, locationname: 'Nagercoil' },
];
test('all 38 districts are offered, not just the three that are running', () => {
assert.equal(TAMIL_NADU_DISTRICTS.length, 38);
assert.ok(districtOptions(live).length >= 38);
});
test('a running district carries the region id it will be saved with', () => {
const options = districtOptions(live);
const coimbatore = options.find((option) => option.name === 'Coimbatore');
assert.equal(coimbatore?.applocationid, 1);
assert.equal(isRunning(coimbatore!), true);
});
// A district Nearle does not run yet carries no id — the form sends its NAME
// instead and the server opens it. Reporting 0 is what makes the form say
// "Erode will be opened" rather than silently saving a partner into nothing.
test('a district Nearle does not run yet is offered, with no id yet', () => {
const erode = districtOptions(live).find((option) => option.name === 'Erode');
assert.ok(erode, 'Erode is a district and belongs in the list');
assert.equal(erode!.applocationid, 0);
assert.equal(isRunning(erode!), false);
});
// Nagercoil is the district's town; the district is Kanyakumari. Matching on
// name means it arrives as its own entry rather than being lost — which is the
// safe direction: a region somebody is already working must never disappear.
test('a running region whose name is not one of the 38 is still offered', () => {
const options = districtOptions(live);
const nagercoil = options.find((option) => option.name === 'Nagercoil');
assert.equal(nagercoil?.applocationid, 23);
});
test('matching is case-insensitive and matches anywhere in the name', () => {
const [erode] = districtOptions(live).filter((option) => option.name === 'Erode');
assert.equal(matchDistrict(erode!, 'ero'), true);
assert.equal(matchDistrict(erode!, 'ROD'), true);
assert.equal(matchDistrict(erode!, ''), true, 'an empty box hides nothing');
assert.equal(matchDistrict(erode!, 'salem'), false);
});
test('with no regions read yet, every district reads as new and none is lost', () => {
const options = districtOptions([]);
assert.equal(options.length, 38);
assert.equal(options.every((option) => !isRunning(option)), true);
});
test('the list is alphabetical, so a long list can be scanned', () => {
const names = districtOptions(live).map((option) => option.name);
assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b)));
});

View File

@@ -1,133 +0,0 @@
/**
* The districts of Tamil Nadu, for the partner onboarding form.
*
* ── Why this is a list in the code ──────────────────────────────────────────
*
* The platform has no district master. `app_location` holds the districts
* Nearle actually RUNS — three of them when this was written: Coimbatore,
* Madurai and Nagercoil — each with a radius, opening hours and an image,
* because it is an operating record rather than a geography one.
*
* A picker built from `app_location` alone can therefore offer three names for
* a state with thirty-eight districts. This list is the geography; the regions
* read back from the API say which of them are already running.
*
* ── Every district can be chosen ────────────────────────────────────────────
*
* Choosing one that is not running yet OPENS it: the partner is sent with a
* district NAME and the server writes the `app_location` and
* `app_locationconfig` rows before creating the partner. Both rows matter —
* `getriders` joins the first and `CreateRider` refuses a region missing the
* second — so opening a district means both or neither.
*
* `isRunning` therefore marks what already exists, not what is permitted. It is
* shown so an operator can tell "this is where we work" from "this will be a
* new district", which is a real operational difference and not a restriction.
*/
/** All 38 districts, alphabetically. Names as the state government writes them. */
export const TAMIL_NADU_DISTRICTS = [
'Ariyalur',
'Chengalpattu',
'Chennai',
'Coimbatore',
'Cuddalore',
'Dharmapuri',
'Dindigul',
'Erode',
'Kallakurichi',
'Kanchipuram',
'Kanyakumari',
'Karur',
'Krishnagiri',
'Madurai',
'Mayiladuthurai',
'Nagapattinam',
'Namakkal',
'Nilgiris',
'Perambalur',
'Pudukkottai',
'Ramanathapuram',
'Ranipet',
'Salem',
'Sivaganga',
'Tenkasi',
'Thanjavur',
'Theni',
'Thoothukudi',
'Tiruchirappalli',
'Tirunelveli',
'Tirupathur',
'Tiruppur',
'Tiruvallur',
'Tiruvannamalai',
'Tiruvarur',
'Vellore',
'Viluppuram',
'Virudhunagar',
] as const;
export type TamilNaduDistrict = (typeof TAMIL_NADU_DISTRICTS)[number];
/** One district as the form sees it: its name, and whether it is running. */
export interface DistrictOption {
name: string;
/** The `app_location` id when the district already runs, 0 when it is new. */
applocationid: number;
}
/**
* The district list, matched against the regions the platform actually runs.
*
* Matched on NAME, case-insensitively, because that is the only thing the two
* sources share — `app_location` has no district column, and its `locationname`
* is the district's name ("Coimbatore", "Madurai", "Nagercoil").
*
* A serviced region whose name is not one of the 38 is still listed rather than
* dropped. Nearle opening a place this file has not heard of is a reason to
* update the file, not a reason to hide a region somebody is already working.
*/
export function districtOptions(
regions: readonly { applocationid: number; locationname?: string }[] = [],
): DistrictOption[] {
const serviced = new Map<string, number>();
for (const region of regions) {
const name = (region.locationname ?? '').trim();
if (name) serviced.set(name.toLowerCase(), region.applocationid);
}
const options: DistrictOption[] = TAMIL_NADU_DISTRICTS.map((name) => ({
name,
applocationid: serviced.get(name.toLowerCase()) ?? 0,
}));
// A running region whose name is not one of the 38 — Nagercoil is the town,
// the district is Kanyakumari — is still listed rather than dropped. A place
// somebody is already working must never disappear from the picker.
const known = new Set(TAMIL_NADU_DISTRICTS.map((name) => name.toLowerCase()));
for (const region of regions) {
const name = (region.locationname ?? '').trim();
if (name && !known.has(name.toLowerCase())) {
options.push({ name, applocationid: region.applocationid });
}
}
return options.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* True when Nearle already runs this district.
*
* Not a permission — every district can be chosen. This distinguishes "we work
* here" from "this will be opened", which the form says out loud because
* opening a district writes rows and is worth knowing before you press save.
*/
export function isRunning(option: DistrictOption): boolean {
return option.applocationid > 0;
}
/** Filter for the search box. Matches anywhere in the name, case-insensitively. */
export function matchDistrict(option: DistrictOption, term: string): boolean {
const needle = term.trim().toLowerCase();
return needle === '' || option.name.toLowerCase().includes(needle);
}

View File

@@ -231,6 +231,23 @@ export function OnboardingPage() {
persist(completeStep(state, step, nextOf(step)));
}
/*
Each step of the wizard starts at the top of itself.
The steps are STATE, not routes, so the shell's own reset — which keys on the
pathname — never fires here: the URL is `/admin/onboarding` for all seven of
them. Finishing the inventory step from the bottom of a long list and
pressing Save & continue therefore opened the next step already scrolled past
its heading and its explanation, which is exactly the part a person setting
up a shop for the first time needs to read.
Instant, for the same reason the shell's is: this replaces the whole page
body, so there is nothing continuous for a smooth travel to connect.
*/
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [step]);
const heading = HEADINGS[step];
const isFirst = step === 'welcome';
const isLast = step === 'done';
@@ -284,7 +301,7 @@ export function OnboardingPage() {
{step === 'catalogue' ? (
<CatalogueStep
productCount={productCount}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=catalogue')}
onUpload={() => navigate('/admin/uploads?upload=1&setup=catalogue')}
/* The catalogue tab, not the products list. This pointed at
`/admin/inventory` bare, which lands on Products — so "import from
the catalogue" showed a merchant their own empty product list. */
@@ -295,7 +312,7 @@ export function OnboardingPage() {
{step === 'inventory' ? (
<InventoryStep
onDownloadTemplate={() => navigate('/admin/inventory?tab=stock&setup=inventory')}
onUpload={() => navigate('/admin/inventory?tab=products&upload=1&setup=inventory')}
onUpload={() => navigate('/admin/uploads?upload=1&setup=inventory')}
/>
) : null}

View File

@@ -26,7 +26,7 @@ export function CatalogueStep({ productCount, onUpload, onManual }: CatalogueSte
return (
<VStack gap={3} className="ob-panel">
{productCount > 0 ? (
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
You already have {productCount} product{productCount === 1 ? '' : 's'}

View File

@@ -69,7 +69,7 @@ export function DeliveryStep({ value, errors, onChange }: DeliveryStepProps) {
{/* Only when it applies. Nothing below exists for a collection-only shop,
and rendering it disabled would be showing work that is not theirs. */}
{value.offersDelivery === true ? (
<Card padding={3} elevation="low" className="ob-panel">
<Card padding={3} className="ob-panel">
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">

View File

@@ -53,7 +53,7 @@ const TIPS = [
export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepProps) {
return (
<VStack gap={3} className="ob-panel">
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={2}>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
@@ -80,7 +80,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</Card>
<div className="ob-two">
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={2}>
<HStack justify="between" align="start" gap={2} wrap="wrap">
<VStack gap={0.5}>
@@ -128,7 +128,7 @@ export function InventoryStep({ onDownloadTemplate, onUpload }: InventoryStepPro
</VStack>
</Card>
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={1.5}>
<Text type="label" size="sm" weight="semibold">
Tips

View File

@@ -158,7 +158,7 @@ function Group({
children: React.ReactNode;
}) {
return (
<Card padding={3} elevation="low">
<Card padding={3}>
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">

View File

@@ -113,7 +113,7 @@ export function WelcomeStep({
<div className="ob-benefits">
{benefits.map(({ icon: Icon, title, body }) => (
<Card key={title} padding={3} elevation="low">
<Card key={title} padding={3}>
<VStack gap={1}>
<span style={{ color: 'var(--color-brand)' }} aria-hidden>
<Icon size={20} />

View File

@@ -110,9 +110,9 @@ export function StoreQrPanel({
<div
style={{
padding: 10,
borderRadius: 16,
background: '#ffffff',
border: '1px solid var(--color-line)',
borderRadius: 'var(--card-radius)',
background: 'var(--card-bg)',
border: 'var(--card-border)',
lineHeight: 0,
}}
>

View File

@@ -0,0 +1,236 @@
import { useMemo } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { BatteryLow, Info, MapPin, Navigation, WifiOff } from 'lucide-react';
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
import type { Stop } from './dispatchModel';
import { useRiderLive } from '@/queries/hooks';
import { mapStop } from './deliveryTrack';
import {
activeBoard,
agoOf,
concerns,
distanceLabel,
readSnapshot,
type LiveRider,
} from './riderLive';
import './dispatchPanels.css';
/**
* Who is out right now, closest to finishing first.
*
* ── Why the order is "distance to the drop" ─────────────────────────────────
*
* A dispatcher watching a live board is answering one question: who will be
* free next. Sorting by that puts the answer at the top, and it re-sorts itself
* as riders move. Sorting by name or by order count makes the reader do the
* comparison themselves, every fifteen seconds.
*
* ── Live is a claim, and most of these positions are not ────────────────────
*
* The service answers for every rider, with their LAST known fix however old —
* riders on this platform routinely come back with positions days stale and
* nothing in the payload admitting it. So each card carries its age, the map
* draws a stale fix hollow, and anything over an hour old is called out rather
* than mixed in. A board that shows a twelve-day-old dot next to a live one is
* worse than no board.
*/
export function ActivePanel({ stops }: { stops: readonly Stop[] }) {
const mapped = useMemo(() => stops.map(mapStop), [stops]);
/* Only riders carrying something today — polling the whole roster would ask a
third-party service about people who are not working. */
const userids = useMemo(
() => [...new Set(mapped.map((stop) => stop.userid).filter((id) => id > 0))],
[mapped],
);
const live = useRiderLive(userids);
const nameOf = useMemo(() => {
const names = new Map<number, string>();
for (const stop of mapped) {
if (stop.userid > 0 && stop.ridername && !names.has(stop.userid)) {
names.set(stop.userid, stop.ridername);
}
}
return names;
}, [mapped]);
const riders = useMemo(() => {
const out: LiveRider[] = [];
for (const userid of userids) {
const read = readSnapshot(
live.data.get(userid) ?? null,
nameOf.get(userid) ?? `Rider ${userid}`,
);
if (read) out.push(read);
}
return out;
}, [userids, live.data, nameOf]);
/* The drop for whichever order a rider says they are on. Matched on the
order id the RIDER reports, not on our idea of what they should be
carrying — the two disagree the moment somebody reassigns a job. */
const dropOf = useMemo(() => {
const byOrder = new Map<string, (typeof mapped)[number]>();
for (const stop of mapped) if (stop.orderid) byOrder.set(stop.orderid, stop);
return (orderid: string) => {
const stop = byOrder.get(orderid);
if (!stop?.drop) return null;
return {
lat: stop.drop.lat,
lng: stop.drop.lng,
customer: stop.customer,
address: stop.address,
};
};
}, [mapped]);
const board = useMemo(() => activeBoard(riders, dropOf), [riders, dropOf]);
const pins: Pin[] = useMemo(() => {
const out: Pin[] = [];
board.forEach((entry, index) => {
const { live: rider } = entry;
if (rider.lat === null || rider.lng === null) return;
out.push({
id: `rider-${rider.userid}`,
lat: rider.lat,
lng: rider.lng,
label: rider.name,
lines: [
`${rider.status}${rider.orderid ? ` · ${rider.orderid}` : ''}`,
`Reported ${agoOf(rider.ageMs)}`,
entry.toDropM !== null ? `${distanceLabel(entry.toDropM)} from the drop` : '',
].filter(Boolean),
colour: trailColour(index),
// Only a fix from the last minute is drawn as a position. Everything
// else is a last-known point and reads as a ring.
isFaded: rider.freshness !== 'live',
});
const drop = dropOf(entry.orderid);
if (drop) {
out.push({
id: `drop-${entry.orderid}`,
lat: drop.lat,
lng: drop.lng,
label: entry.orderid,
lines: [drop.customer || drop.address || 'Going here'],
colour: '#10b981',
});
}
});
return out;
}, [board, dropOf]);
const stale = board.filter((entry) => entry.live.freshness === 'old').length;
return (
<VStack gap={1.5}>
<Card padding={0}>
<VStack gap={1.5} padding={2}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="label" size="sm" weight="semibold">
Out right now
</Text>
<Text type="body" size="xsm" color="secondary">
{board.length === 0
? 'nobody is carrying anything'
: `${board.length} carrying · closest to their drop first`}
{live.isFetching ? ' · updating…' : ''}
</Text>
</HStack>
<TrailMap
pins={pins}
height={360}
emptyNote={
live.isLoading
? 'Asking the riders where they are…'
: userids.length === 0
? 'Nothing is assigned, so nobody is out.'
: 'No rider has reported a position.'
}
/>
</VStack>
</Card>
{board.length > 0 ? (
<div className="active-grid">
{board.map((entry, index) => {
const rider = entry.live;
const worries = concerns(rider);
return (
<Card key={rider.userid} padding={0}>
<VStack gap={1} padding={2}>
<HStack justify="between" align="center" gap={1}>
<span className="active-name">
<i style={{ background: trailColour(index) }} />
{rider.name}
</span>
<span className="active-fresh" data-fresh={rider.freshness}>
{agoOf(rider.ageMs)}
</span>
</HStack>
<div className="active-distance">
<Navigation size={15} />
<strong>{distanceLabel(entry.toDropM)}</strong>
<span>
{entry.toDropM === null
? 'position or drop unknown'
: 'from the drop, in a straight line'}
</span>
</div>
<div className="active-meta">
<span title="The order they are on">{entry.orderid || '—'}</span>
<span>{entry.customer || entry.address || 'no address'}</span>
</div>
<div className="active-stats">
<span>{rider.status}</span>
<span>{rider.speed === null ? '— km/h' : `${rider.speed.toFixed(0)} km/h`}</span>
<span data-low={rider.battery !== null && rider.battery <= 15}>
{rider.battery === null ? '—' : `${rider.battery}%`}
{rider.isCharging ? ' ⚡' : ''}
</span>
<span>{rider.connection}</span>
</div>
{worries.length > 0 ? (
<div className="active-worry">
{rider.connection === 'none' ? (
<WifiOff size={13} />
) : rider.battery !== null && rider.battery <= 15 ? (
<BatteryLow size={13} />
) : (
<MapPin size={13} />
)}
{worries.join(' · ')}
</div>
) : null}
</VStack>
</Card>
);
})}
</div>
) : null}
<div className="pva-note">
<Info size={13} />
<span>
Positions come from the rider app and are shown with their age — a filled pin is a fix
from the last minute, a ring is older. Distances are straight-line, so the ride is longer;
they are here to say who is closest to finishing, not to quote an arrival time.
{stale > 0
? ` ${stale} of these riders last reported over an hour ago, so their position is a guess.`
: ''}
</span>
</div>
</VStack>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Selector } from '@astryxdesign/core/Selector';
import { Bike, Route, Truck, UserCheck } from 'lucide-react';
import { Route, TriangleAlert, UserCheck } from 'lucide-react';
import { errorMessage } from '@/api/client';
import {
RIDER_MESSAGE,
@@ -13,7 +13,8 @@ import type { OrderRow, TenantLocation } from '@/api/types';
import { useAllPartners, useOwnTenant, useRiders } from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { useBranchScope } from './BranchScope';
import { buildDeliveries, riderName, riderScope, riderVehicle } from './assignDelivery';
import { buildDeliveries, riderName, riderVehicle } from './assignDelivery';
import { hasNoDevice } from './riderReach';
import { RoutePlanDrawer } from './RoutePlanDrawer';
import './pages/deliveries.css';
@@ -35,11 +36,20 @@ export interface AssignBarProps {
orders: OrderRow[];
branchOf: (row: OrderRow) => TenantLocation | undefined;
assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
onClear: () => void;
onDone: () => void;
}
export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: AssignBarProps) {
export function AssignBar({
orders,
branchOf,
assigned,
released,
onClear,
onDone,
}: AssignBarProps) {
const client = useQueryClient();
const [riderId, setRiderId] = useState('');
const [outcome, setOutcome] = useState<string | null>(null);
@@ -60,7 +70,19 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
const { tenantid } = useBranchScope();
const shop = useOwnTenant(tenantid || undefined);
const partnerid = Number((shop.data as unknown as Record<string, number>)?.['partnerid'] ?? 0);
const [source, setSource] = useState<'own' | 'partner'>(partnerid > 0 ? 'partner' : 'own');
/*
Both fleets are read, and the picker lists them together.
This used to be an either/or toggle, so a shop with a partner had to switch
back and forth to compare who was free — and the whole question at this
moment is "who can take this", across everybody available. Two reads and one
grouped list answers it in a glance instead.
Each rider still shows whose they are, because they are different people with
different employers and an operator handing over a parcel should know which.
*/
const ownRiders = useRiders(tenantid ? { tenantid } : {});
const partnerRiders = useRiders(partnerid ? { partnerid } : {});
// The partner's NAME on the tab, not "Partner riders". An operator handing an
// order to Xpress-Cbe-Main should read that, not a category.
@@ -76,14 +98,22 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
2026-09-09: 117 of the platform's 118 riders belong to a partner, so region
scope was quietly offering other companies' fleets.
*/
const riders = useRiders(riderScope({ tenantid, partnerid, source }));
const fleet = riders.data ?? [];
const riders = { isLoading: ownRiders.isLoading || partnerRiders.isLoading };
const fleet = useMemo(
() => [...(ownRiders.data ?? []), ...(partnerRiders.data ?? [])],
[ownRiders.data, partnerRiders.data],
);
/** Whose rider this is, for the label beside their name. */
const ownIds = useMemo(
() => new Set((ownRiders.data ?? []).map((entry) => entry.userid)),
[ownRiders.data],
);
const rider = fleet.find((entry) => String(entry.userid) === riderId);
const send = useMutation({
mutationFn: async () => {
if (!rider) throw new Error('Pick a rider first');
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned);
const { drafts, skipped } = buildDeliveries(orders, rider, branchOf, new Date(), assigned, released);
if (drafts.length === 0) {
throw new Error(skipped[0]?.reason ?? 'None of these orders can be assigned');
}
@@ -125,10 +155,24 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
onError: (error) => setOutcome(errorMessage(error)),
});
const options = fleet.map((entry) => ({
value: String(entry.userid),
label: riderVehicle(entry) ? `${riderName(entry)} · ${riderVehicle(entry)}` : riderName(entry),
}));
/*
Whose rider, on every row.
The two fleets are one list now, so the label has to carry the employer —
otherwise a shop with a partner reads eleven names and cannot tell which of
them it pays. The vehicle stays because it is the other thing an operator
picks on.
*/
const options = fleet.map((entry) => {
const whose = ownIds.has(entry.userid) ? 'yours' : partnerName || 'partner';
const vehicle = riderVehicle(entry);
return {
value: String(entry.userid),
label: vehicle
? `${riderName(entry)} · ${vehicle} · ${whose}`
: `${riderName(entry)} · ${whose}`,
};
});
return (
<div className="assign-bar" role="region" aria-label="Assign selected orders to a rider">
@@ -137,37 +181,6 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
{orders.length} selected
</span>
{/* Only when there is a choice to make. A shop with no partner has one
source, and a toggle with one option is a control that asks a question
it already knows the answer to. */}
{partnerid > 0 ? (
<div className="assign-source" role="group" aria-label="Which riders to offer">
<button
type="button"
className="assign-source-btn"
aria-pressed={source === 'own'}
onClick={() => {
setSource('own');
setRiderId('');
}}
>
<Bike size={13} />
Own riders
</button>
<button
type="button"
className="assign-source-btn"
aria-pressed={source === 'partner'}
onClick={() => {
setSource('partner');
setRiderId('');
}}
>
<Truck size={13} />
{partnerName || 'Partner riders'}
</button>
</div>
) : null}
<div className="assign-bar-picker">
<Selector
@@ -186,23 +199,41 @@ export function AssignBar({ orders, branchOf, assigned, onClear, onDone }: Assig
rather than being flattened to "Select…". "No riders on duty" is a
shift that has not started; the region case is a branch somebody
has to configure. Neither is a fault in the orders. */
/* Four situations, and the placeholder names which one — an operator
looking at an empty list needs to know whether to wait, to hire, or
to ask for a partner. */
/* An empty list has three different causes and the operator needs to
know which: wait for somebody to clock on, hire a rider, or ask
Nearle for a partner. Naming the fleets that were searched is what
distinguishes them. */
placeholder={
riders.isLoading
? 'Loading riders…'
: options.length > 0
? 'Select a rider…'
: source === 'partner'
? `No ${partnerName || 'partner'} rider has clocked on today`
: partnerid > 0
? 'This shop has no riders of its own'
: 'No rider has clocked on today'
: partnerid > 0
? 'No riders or partners on duty'
: 'No riders on duty (no partner)'
}
/>
</div>
{/*
Said before the hand-off, not after it.
A rider who has never opened the app has no device to push to, so the
job lands in a queue nobody is told about. That used to surface as
"NOT notified" in the outcome line AFTER the deliveries were written —
by which point the only remedy is a phone call, and one navigation
later there was no record of it at all.
It disables nothing. Assigning to a rider you are about to ring is a
legitimate thing to do; being surprised by it afterwards is not.
*/}
{rider && hasNoDevice(rider) ? (
<span className="assign-bar-warn" role="status">
<TriangleAlert size={14} />
No app on this rider&rsquo;s phone yet — they will not be told. Call them.
</span>
) : null}
{/* Two ways to commit the same selection, and the difference is worth the
second button: "Assign" hands the orders over as they are, which is
right for one or two. "Plan the route" sequences them first — real

View File

@@ -0,0 +1,362 @@
import { useMemo, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { HStack } from '@astryxdesign/core/HStack';
import { AlertTriangle, Bike, Sparkles, UserMinus } from 'lucide-react';
import { optimiserApi, OptimiserError } from '@/api/optimiser';
import { deliveriesApi, RIDER_MESSAGE, RiderNotReachableError } from '@/api/deliveries';
import type { OrderRow, RiderInfo, TenantLocation } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { Drawer } from './Drawer';
import { Badge, DrawerButton, DrawerCard, Note, Row, Section } from './drawerKit';
import { buildDeliveries } from './assignDelivery';
import {
absentFrom,
branchFor,
buildRequest,
committable,
readPlan,
TUNINGS,
unmatched,
type Plan,
type Tuning,
} from './autoAssign';
import { moneyExact } from './format';
import './dispatchPanels.css';
/**
* Let the optimiser propose the round, then commit it here.
*
* ── Two steps, deliberately ─────────────────────────────────────────────────
*
* Running the solver writes nothing. It answers with a PROPOSAL, the operator
* reads it, and only "Assign" turns it into deliveries. That separation is the
* whole design: a solver that assigned directly would be a button that
* dispatches every waiting order to riders nobody has looked at, and the first
* time it got a round wrong there would be no moment at which anyone could have
* caught it.
*
* ── Committed through the same door as the manual bar ───────────────────────
*
* `buildDeliveries` → `deliveriesApi.assign`, exactly as `AssignBar` does. Not
* a shortcut: `assignDelivery` is where the knowledge lives about which ids a
* delivery must carry to be joinable afterwards, and a second write path here
* would be a second place for that to drift. The solver decides WHO; it does
* not get to decide what a delivery row looks like.
*
* One call for the whole plan, because one call is one transaction — several
* riders' rows in a single array is what the endpoint takes and what the old
* console sends.
*/
export function AutoAssignDrawer({
orders,
branches,
fleet,
assigned,
released,
onClose,
onDone,
}: {
/** The waiting orders this run is for. */
orders: readonly OrderRow[];
branches: readonly TenantLocation[];
/** Riders we know about, for the absentee picker and for naming. */
fleet: readonly RiderInfo[];
assigned: ReadonlySet<number>;
/** Orders whose delivery is dead — see `releasedFrom`. */
released?: ReadonlySet<number>;
onClose: () => void;
onDone: () => void;
}) {
const client = useQueryClient();
const [tuning, setTuning] = useState<Tuning>('balanced');
const [absent, setAbsent] = useState<ReadonlySet<number>>(new Set());
const [plan, setPlan] = useState<Plan | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const abort = useRef<AbortController | null>(null);
const branchOf = useMemo(
() => (row: OrderRow) => branchFor(row, branches),
[branches],
);
const solve = useMutation({
mutationFn: async () => {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const away = fleet.filter((rider) => absent.has(rider.userid)).map(absentFrom);
const response = await optimiserApi.assign(
buildRequest(orders, away),
tuning,
controller.signal,
);
return readPlan(response, orders);
},
onSuccess: (next) => {
setPlan(next);
setOutcome(null);
},
onError: (error) => {
setPlan(null);
setOutcome(
error instanceof OptimiserError ? error.message : 'The optimiser run failed.',
);
},
});
const commit = useMutation({
mutationFn: async () => {
if (!plan) throw new Error('Run the optimiser first');
/* Every proposal's rows, built through the same path the manual bar uses
and sent as one array — one call is one transaction. */
const drafts = plan.proposals.flatMap((proposal) => {
const rows = committable(proposal);
// Only `userid` is read off the rider when a delivery is built; the
// rest of the roster row matters for notifying, which happens after.
const rider = (fleet.find((r) => r.userid === proposal.userid) ??
({ userid: proposal.userid } as RiderInfo));
return buildDeliveries(rows, rider, branchOf, new Date(), assigned, released).drafts;
});
if (drafts.length === 0) {
throw new Error('Nothing in this plan can be assigned.');
}
await deliveriesApi.assign(drafts);
return drafts.length;
},
onSuccess: async (count) => {
await client.invalidateQueries({ queryKey: queryKeys.insights.all });
setOutcome(`${count} order${count === 1 ? '' : 's'} assigned · telling the riders…`);
onDone();
/* Notified after the write and reported separately: the deliveries exist
either way, so a failed push must not read as a failed assignment —
but it must still be visible, because a rider who was never told has
work sitting unseen. */
const failures: string[] = [];
for (const proposal of plan?.proposals ?? []) {
const rider = fleet.find((r) => r.userid === proposal.userid);
try {
await deliveriesApi.notify(
rider?.userfcmtoken ?? '',
RIDER_MESSAGE.assigned(proposal.stops.length),
);
} catch (error) {
failures.push(
error instanceof RiderNotReachableError
? `${proposal.rider} has no device registered`
: `${proposal.rider} could not be reached`,
);
}
}
setOutcome(
failures.length === 0
? `${count} order${count === 1 ? '' : 's'} assigned · every rider notified`
: `${count} assigned · NOT notified: ${failures.join('; ')} — tell them another way`,
);
setPlan(null);
},
onError: (error) =>
setOutcome(error instanceof Error ? error.message : 'The assignment failed.'),
});
const isRunning = solve.isPending;
const missing = plan ? unmatched(plan) : [];
const canCommit = Boolean(plan && plan.proposals.length > 0 && !commit.isPending);
return (
<Drawer
title="Auto-assign"
subtitle={`${orders.length} order${orders.length === 1 ? '' : 's'} waiting`}
width={620}
onClose={onClose}
isFooterSpread
footer={
<>
<DrawerButton
label={isRunning ? 'Cancel' : 'Close'}
variant="ghost"
onClick={() => {
if (isRunning) abort.current?.abort();
else onClose();
}}
/>
{plan ? (
<DrawerButton
label={commit.isPending ? 'Assigning…' : `Assign ${plan.meta.assigned}`}
variant="primary"
icon={<Bike size={15} />}
isDisabled={!canCommit}
onClick={() => commit.mutate()}
/>
) : (
<DrawerButton
label={isRunning ? 'Working…' : 'Run the optimiser'}
variant="primary"
icon={<Sparkles size={15} />}
isDisabled={isRunning || orders.length === 0}
onClick={() => solve.mutate()}
/>
)}
</>
}
>
<VStack gap={2}>
<Note icon={<Sparkles size={15} />}>
The optimiser proposes who carries what. Nothing is assigned until you press Assign, so
running it is always safe — and running it again just replaces the proposal.
</Note>
{/* ── Before the run ──────────────────────────────────────────── */}
{!plan ? (
<>
<Section title="How to solve it">
<div className="aa-tunings">
{TUNINGS.map((option) => (
<button
key={option.id}
type="button"
className="aa-tuning"
data-active={tuning === option.id}
disabled={isRunning}
onClick={() => setTuning(option.id)}
>
{option.label}
</button>
))}
</div>
</Section>
<Section title="Anybody off today?">
<DrawerCard>
<Row
label="Absent riders"
value="Ticked riders are left out of the plan. Leaving this empty is normal — the solver then considers everyone."
isStacked
/>
{fleet.length === 0 ? (
<Row
label="Fleet"
value="No riders are on duty, so there is nobody to mark absent."
isStacked
/>
) : (
<div className="aa-absent">
{fleet.map((rider) => {
const name =
`${rider.firstname ?? ''} ${rider.lastname ?? ''}`.trim() ||
rider.fullname?.trim() ||
`Rider ${rider.userid}`;
return (
<label key={rider.userid} className="aa-absent-row">
<input
type="checkbox"
checked={absent.has(rider.userid)}
disabled={isRunning}
onChange={() =>
setAbsent((prev) => {
const next = new Set(prev);
if (next.has(rider.userid)) next.delete(rider.userid);
else next.add(rider.userid);
return next;
})
}
/>
<UserMinus size={13} />
{name}
</label>
);
})}
</div>
)}
</DrawerCard>
</Section>
</>
) : null}
{/* ── The proposal ────────────────────────────────────────────── */}
{plan?.blocked ? (
<div className="aa-blocked">
<AlertTriangle size={16} />
<div>
<strong>The optimiser could not consider anybody</strong>
<span>{plan.blocked}</span>
</div>
</div>
) : null}
{plan && plan.proposals.length > 0 ? (
<Section
title={`${plan.meta.assigned} of ${plan.meta.totalOrders} placed with ${plan.proposals.length} rider${plan.proposals.length === 1 ? '' : 's'}`}
>
{plan.proposals.map((proposal) => (
<DrawerCard key={proposal.userid}>
<Row
label={proposal.rider}
value={
<Badge
label={`${proposal.stops.length} stop${proposal.stops.length === 1 ? '' : 's'}`}
colour="var(--color-brand)"
/>
}
/>
{proposal.stops.map((stop) => (
<Row
key={stop.orderid || stop.orderheaderid}
label={stop.orderid || `#${stop.orderheaderid}`}
value={
stop.row
? moneyExact(
stop.row.ordervalue || stop.row.orderamount || stop.row.deliveryamt || 0,
)
: 'not in this list'
}
/>
))}
</DrawerCard>
))}
{plan.meta.profit > 0 ? (
<Note>
The optimiser puts this plan at {moneyExact(plan.meta.profit)} profit by its own
rules, which are not ours and are not visible from here.
</Note>
) : null}
</Section>
) : null}
{missing.length > 0 ? (
<Note icon={<AlertTriangle size={15} />}>
{missing.length} proposed order{missing.length === 1 ? '' : 's'} could not be matched
back to this list and will not be assigned: {missing.join(', ')}.
</Note>
) : null}
{plan && plan.unassigned.length > 0 ? (
<Section title={`${plan.unassigned.length} left unassigned`}>
<DrawerCard>
{plan.unassigned.slice(0, 12).map((entry) => (
<Row key={entry.orderid} label={entry.orderid} value={entry.reason} isStacked />
))}
{plan.unassigned.length > 12 ? (
<Row
label="…"
value={`and ${plan.unassigned.length - 12} more, for the same reasons`}
/>
) : null}
</DrawerCard>
</Section>
) : null}
{outcome ? (
<HStack gap={1} align="center">
<Text type="body" size="sm">
{outcome}
</Text>
</HStack>
) : null}
</VStack>
</Drawer>
);
}

View File

@@ -0,0 +1,261 @@
/**
* The branch filter, mounted for real.
*
* ── Why this test exists ────────────────────────────────────────────────────
*
* The filter silently reset to "All branches" on every navigation, and nothing
* in the codebase could see it. The selection was derived straight from the
* `?branch=` search param, and every nav link in `AppShell` is a bare path
* (`to="/admin/sales"`), so React Router replaced the whole location and the
* param went with it. An absent param read as All.
*
* No pure-function test could catch that: the bug only exists in the
* interaction between the provider, the router and a link that drops the query
* string. So this mounts the provider inside a real router, navigates the way
* the shell does, and asserts the selection survives.
*
* The assertions below are mostly about what must NOT change: a nav click must
* not widen the operator's scope, and the URL must still be able to set it, or
* a shared link to one shop stops working.
*/
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { JSDOM } from 'jsdom';
import type { TenantLocation } from '@/api/types';
const BRANCHES = [
{ locationid: 1097, tenantid: 1087, locationname: 'Ragul stores', status: 'Active' },
{ locationid: 1135, tenantid: 1087, locationname: 'Ragul Selvapuram', status: 'Active' },
{ locationid: 1138, tenantid: 1087, locationname: 'Deborah Lara', status: 'Active' },
] as TenantLocation[];
interface Harness {
/** What the provider currently reports. */
read: () => { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean };
/** Pick a branch through the provider's own `select`. */
pick: (next: number | null) => Promise<void>;
/** Navigate the way `AppShell` does — a bare path, no query string. */
navigate: (to: string) => Promise<void>;
}
let mount: (opts: { url: string; pin?: number }) => Promise<Harness>;
/* Everything a mount creates, so it can be torn down. Without this the file
never exits: each mount leaves a live QueryClient with an active observer,
and node:test waits on the open handles until the suite times out — every
test passing and the FILE reported as failed. */
const created: { unmount: () => void }[] = [];
let closeDom: () => void = () => {};
before(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: 'http://localhost/admin/console',
pretendToBeVisual: true,
});
const win = dom.window as unknown as Record<string, unknown>;
const g = globalThis as Record<string, unknown>;
for (const key of [
'window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement', 'Event',
'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame',
'localStorage', 'sessionStorage', 'MouseEvent', 'CustomEvent',
]) g[key] = win[key];
Object.defineProperty(globalThis, 'navigator', { value: win['navigator'], configurable: true });
g['ResizeObserver'] = class { observe() {} unobserve() {} disconnect() {} };
/* No network. Without this the test is not hermetic and it silently was not:
`useTenantLocations` fetched the REAL tenant 1087 from fiesta.nearle.app and
replaced the three seeded branches with the six that shop actually has, so
two assertions failed against production data that has nothing to do with
what is under test — and would fail differently the day someone opens a
seventh outlet. */
g['fetch'] = () => Promise.reject(new Error('no network in tests'));
const React = await import('react');
const { createRoot } = await import('react-dom/client');
const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom');
const { BranchScopeProvider, useBranchScope } = await import('./BranchScope');
/* The branch list, without the network. `useTenantLocations` is a TanStack
hook; stubbing the module would mean stubbing the query client too, so the
provider is given a real one whose fetch resolves immediately. */
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
const { AuthContext } = await import('@/auth/context');
closeDom = () => dom.window.close();
mount = async ({ url, pin }) => {
const host = dom.window.document.createElement('div');
dom.window.document.body.appendChild(host);
const qc = new QueryClient({
// No gcTime: 0 here. It collects the seeded entry before any component has
// subscribed to it, so the provider saw an empty branch list and every
// assertion read null.
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Seed the cache under the key `useTenantLocations` reads, so the provider
// sees a loaded branch list on first render.
const { queryKeys } = await import('@/queries/keys');
qc.setQueryData(queryKeys.tenants.locations(1087), BRANCHES);
let api: { selected: number | null; scopedCount: number; search: string; branchCount: number; isLoading: boolean } | null = null;
let doSelect: ((n: number | null) => void) | null = null;
let doNavigate: ((to: string) => void) | null = null;
function Probe() {
const scope = useBranchScope();
const navigate = useNavigate();
const location = useLocation();
api = {
selected: scope.selected,
scopedCount: scope.scoped.length,
search: location.search,
branchCount: scope.branches.length,
isLoading: scope.isLoading,
};
doSelect = scope.select;
doNavigate = (to) => navigate(to);
return null;
}
const auth = {
user: {
userid: 1, role: 'store-admin', name: 'A', email: 'a@b.c',
roleid: 1, tenantid: 1087, locationid: 0, issuperadmin: false,
},
isLoading: false,
signIn: async () => { throw new Error('not used'); },
signOut: () => {},
};
const root = createRoot(host);
created.push({ unmount: () => { root.unmount(); qc.unmount(); qc.clear(); } });
root.render(
React.createElement(
QueryClientProvider, { client: qc },
React.createElement(
AuthContext.Provider, { value: auth as never },
React.createElement(
MemoryRouter, { initialEntries: [url] },
React.createElement(
Routes, null,
React.createElement(Route, {
path: '/admin/*',
// children passed in the props object, not as a third argument:
// BranchScopeProvider declares children as required, and
// createElement's overload will not accept a null props object.
element: React.createElement(BranchScopeProvider, {
pin,
children: React.createElement(Probe),
}),
}),
),
),
),
),
);
const settle = () => new Promise((r) => setTimeout(r, 60));
await settle();
return {
read: () => api!,
pick: async (next) => { doSelect!(next); await settle(); },
navigate: async (to) => { doNavigate!(to); await settle(); },
};
};
});
/* ── The bug ─────────────────────────────────────────────────────────────── */
// THE regression test. A nav click replaces the whole location, query string
// included; that must not change which shop the operator is looking at.
test('a nav click does not reset the chosen branch', async () => {
const h = await mount({ url: '/admin/console' });
await h.pick(1135);
assert.equal(h.read().selected, 1135);
await h.navigate('/admin/sales');
assert.equal(h.read().selected, 1135, 'navigating to Sales widened the scope back to All');
await h.navigate('/admin/inventory');
assert.equal(h.read().selected, 1135, 'the reset appeared on the second hop');
});
// Scope is what pages actually read, so it has to narrow with the selection —
// a label that changes while every page keeps reading all six is the same bug
// wearing a different hat.
test('the scope pages read narrows to the one branch, and survives too', async () => {
const h = await mount({ url: '/admin/console' });
assert.equal(h.read().scopedCount, 3, 'All branches should scope to every outlet');
await h.pick(1138);
assert.equal(h.read().scopedCount, 1);
await h.navigate('/admin/reports');
assert.equal(h.read().scopedCount, 1, 'scope widened on navigation');
});
// The URL is a mirror, and it has to be put back after a nav click dropped it,
// or the address bar quietly disagrees with the control.
test('the param is written back after navigation drops it', async () => {
const h = await mount({ url: '/admin/console' });
await h.pick(1135);
assert.match(h.read().search, /branch=1135/);
await h.navigate('/admin/sales');
assert.match(h.read().search, /branch=1135/, 'the URL lost the branch it is scoped to');
});
/* ── What must keep working ──────────────────────────────────────────────── */
// The reason the param existed in the first place: a link to one shop's
// inventory has to survive being pasted into a chat.
test('an explicit param in the URL still sets the branch', async () => {
const h = await mount({ url: '/admin/inventory?branch=1138' });
assert.equal(h.read().selected, 1138);
});
// The id is user-editable, so it is untrusted. An outlet this tenant does not
// own falls back to All rather than rendering an empty page.
test('an id this tenant does not own falls back to All', async () => {
const h = await mount({ url: '/admin/console?branch=999999' });
assert.equal(h.read().selected, null);
assert.equal(h.read().scopedCount, 3);
});
test('branch=all is honoured, and switching back to All works', async () => {
const explicit = await mount({ url: '/admin/console?branch=all' });
assert.equal(explicit.read().selected, null);
const h = await mount({ url: '/admin/console' });
await h.pick(1097);
assert.equal(h.read().selected, 1097);
await h.pick(null);
assert.equal(h.read().selected, null, 'could not get back to All branches');
assert.doesNotMatch(h.read().search, /branch=/, 'the param should be removed under All');
});
/* ── The pinned workspace ────────────────────────────────────────────────── */
// Fiesta authorises a POS or catalogue read on locationid alone, so for a store
// user the URL param is not a filter — it is the authorisation boundary. The
// pin has to win over anything in the address bar.
test('a pinned branch ignores the URL and cannot be selected away from', async () => {
const h = await mount({ url: '/admin/console?branch=1138', pin: 1097 });
assert.equal(h.read().selected, 1097, 'the URL overrode the session pin');
assert.equal(h.read().scopedCount, 1);
await h.pick(1135);
assert.equal(h.read().selected, 1097, 'select() moved a pinned scope');
await h.navigate('/admin/sales');
assert.equal(h.read().selected, 1097);
});
/* ── Teardown ────────────────────────────────────────────────────────────── */
after(() => {
for (const c of created) c.unmount();
closeDom();
});

View File

@@ -1,4 +1,11 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations } from '@/queries/hooks';
@@ -32,16 +39,18 @@ export interface BranchScopeValue {
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
/** The URL param. In the URL so a link to a page carries its branch with it. */
/** The URL param. Mirrors the selection so a link carries its branch with it. */
const PARAM = 'branch';
/**
* Which branch the Store Admin is looking at.
*
* Held in the URL rather than in component state for two reasons. A link to
* "Inventory, Peelamedu" has to survive being pasted into a chat, and a reload
* during a shift must not silently drop the operator back to All branches while
* they are reading a number that only makes sense for one shop.
* Held in component state, mirrored to the URL. The mirror is what lets a link
* to "Inventory, Peelamedu" survive being pasted into a chat; holding the state
* here rather than reading it back out of the address bar is what stops a nav
* click — which replaces the query string — from resetting the operator to All
* branches mid-shift while they read a number that only means anything for one
* shop. See the long note on `chosen` below.
*
* The tenant, by contrast, comes from the session and is deliberately NOT in
* the URL. Fiesta has no web auth, so tenant scoping is enforced by this client
@@ -83,15 +92,68 @@ export function BranchScopeProvider({
);
const raw = params.get(PARAM);
const parsed = raw === null || raw === 'all' ? null : Number(raw);
// An id in the URL that this tenant does not own falls back to All rather
// than showing an empty page — the id is user-editable, so it is untrusted.
const selected =
pin !== undefined
? pin
: parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
? parsed
: null;
/*
The selection lives here, and the URL only mirrors it.
── The bug this fixes ──────────────────────────────────────────────────────
It used to be derived straight from the search param, with an absent param
meaning All branches. That is wrong, because absent does not mean "show me
everything" — it mostly means "you just clicked a nav tab". Every link in
`AppShell` is a bare path (`to="/admin/sales"`), so React Router replaces the
whole location, query string included, and the param is simply gone. The
branch filter therefore reset to All on every navigation: pick a shop on
Console, click Sales, and you were back to all six with nothing saying so.
Reproduced on tenant 1087 (Ragul Stores, 6 branches) — the label went from
"Ragul stores Selvapuram" back to "All branches (6)".
Copying the whole search string onto the nav links would have fixed it and
broken something else: `InventoryPage` and the global catalogue keep their own
params, and those would then follow the operator from page to page.
── The rule ────────────────────────────────────────────────────────────────
A param that is PRESENT is obeyed, so a link to "Inventory, Peelamedu" still
survives being pasted into a chat, and editing the id in the address bar still
works. A param that is ABSENT changes nothing, so navigation cannot silently
widen the operator's scope. The effect below then writes the param back, which
is what keeps the URL honest after a nav click.
*/
const [chosen, setChosen] = useState<BranchSelection>(null);
useEffect(() => {
if (pin !== undefined || raw === null) return;
if (raw === 'all') {
setChosen(null);
return;
}
const parsed = Number(raw);
if (Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)) {
setChosen(parsed);
} else if (!isLoading) {
// An id this tenant does not own falls back to All rather than showing an
// empty page — the id is user-editable, so it is untrusted. Guarded on
// `isLoading` because `branches` is empty until the fetch lands, and
// resetting then would throw away a perfectly good deep link.
setChosen(null);
}
}, [raw, branches, isLoading, pin]);
const selected = pin !== undefined ? pin : chosen;
// The URL follows the selection, including putting the param back after a nav
// click has dropped it. Built from the current params so a page's own query
// state is carried through untouched.
useEffect(() => {
if (pin !== undefined) return;
const want = selected === null ? null : String(selected);
if ((params.get(PARAM) ?? null) === want) return;
const next = new URLSearchParams(params);
if (want === null) next.delete(PARAM);
else next.set(PARAM, want);
setParams(next, { replace: true });
}, [selected, params, setParams, pin]);
const value = useMemo<BranchScopeValue>(() => {
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
@@ -103,15 +165,14 @@ export function BranchScopeProvider({
current,
isPinned: pin !== undefined,
scoped: current ? [current] : branches,
// State only. The URL is updated by the mirroring effect above, so there
// is one place that writes the param rather than two that can disagree.
select: (next) => {
if (pin !== undefined) return;
const nextParams = new URLSearchParams(params);
if (next === null) nextParams.delete(PARAM);
else nextParams.set(PARAM, String(next));
setParams(nextParams, { replace: true });
setChosen(next);
},
};
}, [branches, isLoading, tenantid, selected, params, setParams, pin]);
}, [branches, isLoading, tenantid, selected, pin]);
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
}

View File

@@ -1,13 +1,20 @@
import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { EyeOff } from 'lucide-react';
import { AlertTriangle, EyeOff, Hash, MapPin, Monitor, Receipt } from 'lucide-react';
import { Drawer } from './Drawer';
import {
Badge,
DrawerButton,
DrawerCard,
Field,
Metric,
Metrics,
Note,
Row,
Section,
TextField,
} from './drawerKit';
import type { CounterLabels } from './counterLabels';
import type { CounterRow } from './counterRows';
import { COUNTER_STATE_COLOUR, COUNTER_STATE_LABEL, type CounterRow } from './counterRows';
import { money } from './format';
import { shortAge } from './posStatus';
@@ -21,6 +28,19 @@ import { shortAge } from './posStatus';
* Rename and hide live here rather than on the row. They are rare actions, and
* a control on every row of a fifteen-row table is fifteen chances to hide a
* counter by mistake.
*
* ── What this used to get wrong ─────────────────────────────────────────────
*
* The table's Status column — Active, Attention, Offline, Never used — was the
* one reading this drawer did not repeat, so the chip that made somebody open a
* counter vanished the moment they did. It is in the header now, from the same
* `state` the row chips from, and the named problem behind an "Attention" is
* stated rather than left as a colour.
*
* The readings were also drawn by a `.reading-list` belonging to the counters
* page stylesheet, which is why this drawer had a different row height, label
* weight and hairline from every other drawer in the console. It is built from
* the kit now, like the rest.
*/
export interface CounterDrawerProps {
row: CounterRow;
@@ -33,78 +53,127 @@ export function CounterDrawer({ row, labels, onClose }: CounterDrawerProps) {
labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : '',
);
const status = row.status;
const readings: [string, string][] = [
['Counter code', row.terminalId],
['Branch', row.branchName],
['Sold in this period', `${row.periodBills} bills · ${money(row.periodAmount)}`],
];
if (status) {
readings.push(['Today on the till', `${status.todayBills} bills · ${money(status.todayAmount)}`]);
readings.push(['Last sale', status.lastBillAt ? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago` : 'never']);
readings.push(['Last heard from', status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`]);
// The number that explains a revenue figure reading low, so it is stated
// even when it is zero rather than hidden as "nothing to report".
readings.push(['Waiting to send', String(status.pendingBills)]);
if (status.reason) readings.push(['Reported', status.reason]);
} else {
readings.push(['Status', 'This counter has never reported in.']);
}
const isNamed = labels.isNamed(row.terminalId);
return (
<Drawer
title={labels.isNamed(row.terminalId) ? labels.nameFor(row.terminalId) : row.terminalId}
subtitle={labels.isNamed(row.terminalId) ? row.terminalId : row.branchName}
title={isNamed ? labels.nameFor(row.terminalId) : row.terminalId}
{...(isNamed ? {} : { isTitleMono: true })}
subtitle={isNamed ? row.terminalId : row.branchName}
width={460}
onClose={onClose}
>
<VStack gap={3}>
<div className="reading-list">
{readings.map(([label, value]) => (
<div key={label} className="reading">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
<VStack gap={1.5}>
<TextInput
label="Name this counter"
value={draft}
onChange={setDraft}
placeholder={row.terminalId}
description="Shown instead of the code. The code stays on the receipts."
/* The same chip the row wears, so the reading that made somebody open
this counter is still on screen once they have. */
meta={
<>
<Badge label={COUNTER_STATE_LABEL[row.state]} colour={COUNTER_STATE_COLOUR[row.state]} />
{isNamed ? <span className="drawer-meta-text">{row.branchName}</span> : null}
</>
}
isFooterSpread
footer={
<>
<DrawerButton
label="Hide this counter"
variant="ghost"
icon={<EyeOff size={14} />}
onClick={() => {
labels.hide(row.terminalId);
onClose();
}}
/>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Button
label="Hide this counter"
variant="ghost"
size="sm"
icon={<EyeOff size={14} />}
onClick={() => {
labels.hide(row.terminalId);
onClose();
}}
/>
<Button
label="Save name"
variant="primary"
size="sm"
isDisabled={draft.trim() === '' && !labels.isNamed(row.terminalId)}
onClick={() => {
labels.rename(row.terminalId, draft.trim());
onClose();
}}
/>
</HStack>
<Text type="body" size="xsm" color="secondary">
Hiding removes a counter from this page. It keeps recording sales — use it for a till
that has been retired.
</Text>
</VStack>
</VStack>
<DrawerButton
label="Save name"
variant="primary"
isDisabled={draft.trim() === '' && !isNamed}
onClick={() => {
labels.rename(row.terminalId, draft.trim());
onClose();
}}
/>
</>
}
>
{/* ── The period, in the figures the table totals ──────────────────── */}
<Metrics cols={2}>
<Metric
label="Sold in this period"
value={money(row.periodAmount)}
note={`${row.periodBills} bill${row.periodBills === 1 ? '' : 's'}`}
/>
<Metric
label="Today on the till"
value={status ? money(status.todayAmount) : '—'}
note={status ? `${status.todayBills} bill${status.todayBills === 1 ? '' : 's'}` : 'never reported'}
isSmall
/>
</Metrics>
{/* The named problem behind an "Attention" or an "Offline". The board
already worked out what is wrong and how long it has been wrong; the
row could only show the colour. */}
{row.card ? (
<Note icon={<AlertTriangle size={15} />}>
<strong>{row.card.problem.headline}</strong> {row.card.problem.detail}
{row.card.problem.action ? (
<>
{' '}
<strong>{row.card.problem.action}</strong>
</>
) : null}
</Note>
) : null}
<Section title="Readings">
<DrawerCard>
<Row label="Counter code" value={row.terminalId} icon={<Hash size={14} />} />
<Row label="Branch" value={row.branchName} icon={<MapPin size={14} />} />
{status ? (
<>
<Row
label="Last sale"
value={
status.lastBillAt
? `${shortAge(Date.now() - status.lastBillAt.getTime())} ago`
: 'never'
}
icon={<Receipt size={14} />}
{...(status.lastBillAt ? {} : { tone: 'muted' as const })}
/>
<Row
label="Last heard from"
value={status.silentForMs == null ? 'never' : `${shortAge(status.silentForMs)} ago`}
icon={<Monitor size={14} />}
{...(status.silentForMs == null ? { tone: 'muted' as const } : {})}
/>
{/* Stated even at zero. It is the number that explains a revenue
figure reading low, and hiding it as "nothing to report" is
what leaves somebody hunting for the missing money. */}
<Row
label="Waiting to send"
value={String(status.pendingBills)}
{...(status.pendingBills > 0 ? {} : { tone: 'muted' as const })}
/>
{status.reason ? <Row label="Reported" value={status.reason} isStacked /> : null}
</>
) : (
<Row label="Status" value="This counter has never reported in." isStacked />
)}
</DrawerCard>
</Section>
<Section title="Name and visibility">
<Field
label="Name this counter"
description="Shown instead of the code. The code stays on the receipts."
>
<TextField value={draft} onChange={setDraft} placeholder={row.terminalId} />
</Field>
<Note>
Hiding removes a counter from this page. It keeps recording sales — use it for a till that
has been retired.
</Note>
</Section>
</Drawer>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { DateRangeInput, type DateRange as AstryxDateRange, type ISODateString } from '@astryxdesign/core';
import type { DateRange } from '@/api/insights';
import './dateRangePicker.css';
export type RangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom';
@@ -24,12 +25,10 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
return { fromdate: isoDay(yesterday), todate: isoDay(yesterday) };
}
case 'week': {
// Monday-first. A retail week that starts on Sunday makes Monday's
// takings the previous week's, which is not how anyone here counts.
const weekday = (today.getDay() + 6) % 7;
const monday = new Date(today);
monday.setDate(today.getDate() - weekday);
return { fromdate: isoDay(monday), todate: isoDay(today) };
// 7-day rolling window as expected by the user for "one week data"
const lastWeek = new Date(today);
lastWeek.setDate(today.getDate() - 6);
return { fromdate: isoDay(lastWeek), todate: isoDay(today) };
}
case 'month': {
const first = new Date(today.getFullYear(), today.getMonth(), 1);
@@ -41,7 +40,6 @@ export function presetRange(preset: RangePreset, now = new Date()): DateRange {
}
export interface DateRangePickerProps {
preset: RangePreset;
range: DateRange;
onChange: (preset: RangePreset, range: DateRange) => void;
}
@@ -58,13 +56,27 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
return [
{ label: 'Today', getRange: () => { const r = presetRange('today'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
{ label: 'Yesterday', getRange: () => { const r = presetRange('yesterday'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
{ label: 'This week', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
/* "Last 7 days", not "This week". `presetRange('week')` is a ROLLING
window now — today less six — so a label reading "This week" named a
calendar week the picker had stopped returning. On a Wednesday the two
differ by four days, and the label was the only thing telling the
reader which one they had asked for. */
{ label: 'Last 7 days', getRange: () => { const r = presetRange('week'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
{ label: 'This month', getRange: () => { const r = presetRange('month'); return { start: r.fromdate as ISODateString, end: r.todate as ISODateString }; } },
];
}, []);
/* No tomorrow.
The calendar had no upper bound, so every future date was selectable: you
could ask this console for next month's takings and it would answer, with
an empty report and no indication that the question was the problem rather
than the shop. A reporting range cannot end in the future — there is
nothing there yet — so the calendar stops at today. */
const today = isoDay(new Date()) as ISODateString;
return (
<div style={{ width: 180 }}>
<div className="datefilter" style={{ width: 180 }}>
<DateRangeInput
width={180}
size="sm"
@@ -72,6 +84,12 @@ export function DateRangePicker({ range, onChange }: DateRangePickerProps) {
isLabelHidden
value={astryxRange}
presets={presets}
max={today}
/* Monday-first. Astryx defaults the grid to Sunday, and a retail week
that starts on Sunday puts Monday's takings in the previous week —
which is not how anyone here counts. This is the calendar GRID only;
it is independent of what the "Last 7 days" preset returns. */
weekStartsOn="mon"
onChange={(newRange) => {
if (!newRange) {
onChange('custom', {});

View File

@@ -7,6 +7,7 @@ import { deliveriesApi } from '@/api/deliveries';
import type { DeliveryRow } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { stampNow } from './assignDelivery';
import { isSettled, isStalled, matchesStatus } from './orderStatus';
/**
* Moving a delivery along from the back office.
@@ -37,8 +38,12 @@ const MOVES = [
{ to: 'cancelled', label: 'Cancelled', stamp: 'canceltime' },
] as const;
/** The end states. Nothing follows them, so nothing is offered. */
const SETTLED = ['delivered', 'cancelled'];
/** What to do about a stalled job, which is never "nothing". */
function stalledAdvice(status: string): string {
return matchesStatus('rejected', status)
? 'This rider declined the job. The order is waiting for a rider again — assign it to somebody else.'
: 'The rider could not hand this over. It is still theirs to re-attempt; reassign it if somebody else should take it.';
}
/**
* The moves, split from their rendering.
@@ -54,7 +59,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase();
const isSettled = SETTLED.includes(status);
const settled = isSettled(status);
const stalled = isStalled(status);
const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
@@ -70,7 +76,8 @@ export function useDeliveryMoves(job: DeliveryRow) {
return {
status,
isSettled,
isSettled: settled,
isStalled: stalled,
isPending: move.isPending,
problem,
moves: MOVES,
@@ -86,7 +93,8 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
const [problem, setProblem] = useState<string | null>(null);
const status = (job.orderstatus ?? '').trim().toLowerCase();
const isSettled = SETTLED.includes(status);
const settled = isSettled(status);
const stalled = isStalled(status);
const move = useMutation({
mutationFn: ({ to, stamp }: { to: string; stamp: string }) =>
@@ -100,7 +108,7 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
onError: (error) => setProblem(errorMessage(error)),
});
if (isSettled) {
if (settled) {
return (
<Text type="body" size="xsm" color="secondary">
This job is {status}. Nothing further to record.
@@ -108,6 +116,14 @@ export function DeliveryProgress({ job }: { job: DeliveryRow }) {
);
}
if (stalled) {
return (
<Text type="body" size="xsm" color="secondary">
This job is {status}. {stalledAdvice(status)}
</Text>
);
}
return (
<VStack gap={1}>
<div className="progress-moves">

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { errorMessage } from '@/api/client';
import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots';
import { Button } from '@astryxdesign/core/Button';
import { DeliverySlotsEditor } from './DeliverySlotsEditor';
/**
* A branch's delivery windows, on the shop profile.
*
* Owns its own fetch and save rather than joining the page's one big record:
* the windows live in their own table with their own endpoint, and folding them
* into the profile's save would mean a failed window edit rolling back a
* perfectly good change of phone number.
*
* ── Editing never moves an order already placed ─────────────────────────────
*
* The server upserts by (tenant, branch, key) and keeps slot ids stable, so an
* order that chose this morning still points at this morning. Changing the
* hours changes what FUTURE shoppers are offered, not what past ones agreed to.
* Worth knowing before anyone decides to make this a delete-and-recreate.
*/
export function DeliverySlotsCard({
tenantid,
locationid,
}: {
tenantid: number;
locationid: number;
}) {
const query = useQuery({
queryKey: ['deliveryslots', tenantid, locationid],
queryFn: () => deliverySlotsApi.list(tenantid, locationid),
enabled: tenantid > 0,
staleTime: 60_000,
});
/*
The working copy.
Seeded from the server once it answers, and from the defaults when the
branch has none — which is every branch until somebody sets them. Local so
a half-finished edit is not thrown away by a background refetch.
*/
const [draft, setDraft] = useState<DeliverySlot[] | null>(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query.data) return;
const fromServer = query.data.details ?? [];
setDraft(fromServer.length > 0 ? fromServer : DEFAULT_SLOTS);
}, [query.data]);
const save = useMutation({
mutationFn: (slots: DeliverySlot[]) => deliverySlotsApi.save(tenantid, locationid, slots),
onMutate: () => {
setError(null);
setSaved(false);
},
onSuccess: () => {
setSaved(true);
void query.refetch();
},
// A write that fails silently would leave a shopkeeper believing their
// delivery hours had changed when they had not — and the people who find
// out are customers.
onError: (cause) => setError(errorMessage(cause)),
});
if (query.isLoading || !draft) {
return <p className="sp-empty">Loading delivery windows…</p>;
}
const problems = slotProblems(draft);
return (
<div>
<DeliverySlotsEditor
slots={draft}
onChange={(next) => {
setDraft(next);
setSaved(false);
}}
isDisabled={save.isPending}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
{/* The design system's Button, matching the Save on every other card on
this page. `DrawerButton` is the drawer's own styling and read as a
different product standing on a profile card. */}
<Button
label={save.isPending ? 'Saving…' : 'Save delivery windows'}
variant="primary"
size="sm"
isLoading={save.isPending}
isDisabled={save.isPending || problems.length > 0}
onClick={() => save.mutate(draft)}
/>
{saved ? (
<span style={{ fontSize: 12, color: 'var(--color-ok, #2f855a)' }}>Saved.</span>
) : null}
{error ? (
<span style={{ fontSize: 12, color: 'var(--color-error, #d64545)' }}>{error}</span>
) : null}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More