initial commit

This commit is contained in:
2026-09-28 17:24:00 +05:30
commit 9706fcc520
213 changed files with 53142 additions and 0 deletions

31
.dockerignore Normal file
View File

@@ -0,0 +1,31 @@
# What must never enter the build context.
#
# There was no .dockerignore at all, so every one of these was being uploaded to
# the Docker daemon on each build and then copied into the builder image by
# `COPY . .`.
#
# `.git` is the one that matters for speed — it carries the entire history, and
# it grows forever while being worthless to a build. The rest is correctness:
# `dist` and `node_modules` from a developer's machine would be copied in and
# then overwritten by the build, but only after being transferred, and a stale
# `dist` copied over the fresh one is a genuinely confusing failure.
.git
.gitignore
node_modules
dist
# Transfer archives. The scratch files these rules were written for — the
# `_to_delete` directory, `_inv.txt`, and two empty archives — are gone from the
# repository; the glob stays so the next one never reaches a build context.
*.zip
*.tgz
# Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and
# the build needs it — but `.env.local` is a developer's private override and
# must not decide what production talks to.
.env.local
*.local
.vscode
.DS_Store
README.md

13
.env Normal file
View File

@@ -0,0 +1,13 @@
# The Fiesta API host. Read by Vite at build time and compiled into the bundle.
#
# This is the single source of truth for where the console talks to the backend,
# and it is not a secret — it is the same public host the customer app calls.
#
# Committed on purpose: a deployed build has to carry it, and a value that lives
# only on one developer's machine is one the build server does not have. Vite
# only exposes `VITE_`-prefixed names to client code, so nothing else in here
# would reach the browser.
#
# `.env.local` overrides this and is gitignored — that is the file to use for a
# staging backend, or `/fiesta` to route through the dev proxy instead.
VITE_API_BASE="https://fiesta.nearle.app"

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# The Fiesta API host used by the production build.
# In development this is left unset and Vite proxies /fiesta -> fiesta.nearle.app
# (see vite.config.ts), which keeps the network tab consistent and avoids
# preflight surprises.
VITE_API_BASE="https://fiesta.nearle.app"

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
node_modules
dist
*.local
.DS_Store
.vscode
# Transfer archives. Nothing of the sort belongs in the repository — the two
# that were here (`_sync.zip`, `_their-src.tgz`) had been committed empty and
# stayed for weeks.
*.zip
*.tgz
# Build output from scripts/mapPreview.mjs — a local viewer, never committed.
scripts/.preview/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=false

131
Dockerfile Normal file
View File

@@ -0,0 +1,131 @@
# Stage 1 — build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
# `npm ci`, with no `|| npm install` fallback.
#
# That fallback was here and it is worse than the error it hides. `npm ci`
# fails only when package-lock.json disagrees with package.json — exactly the
# case where falling back to `npm install` resolves fresh versions the lock file
# never pinned, so the deployed bundle is built from dependencies nobody chose
# and nobody can reproduce.
#
# When this line fails, the fix is to update package-lock.json locally and
# COMMIT it. The build should not paper over a lock file that is out of date;
# it should say so.
#
# ── But `npm install` alone is how the lock got broken once ─────────────────
#
# This image is node:22-alpine, which ships npm 10.9.8. A developer on npm 11
# running `npm install` rewrites the lock in a shape npm 10 rejects: npm 11
# prunes optional platform packages that npm 10 still validates. Adding leaflet
# on npm 11.6.2 dropped `@emnapi/core` and `@emnapi/runtime` — transitive
# optional deps of `@tailwindcss/oxide-wasm32-wasi` — and the next deploy died
# here with "Missing: @emnapi/core@1.11.3 from lock file". Nothing was wrong
# with the code; the lock was genuinely incomplete and this line was right to
# refuse it.
#
# So after changing dependencies, prove the lock against THIS npm before
# pushing, in a scratch directory so node_modules is not disturbed:
#
# mkdir /tmp/lockcheck && cp package.json package-lock.json /tmp/lockcheck/
# cd /tmp/lockcheck && npx npm@10.9.8 ci
#
# and if it fails, regenerate with the same version:
#
# npx npm@10.9.8 install --package-lock-only
RUN npm ci --no-audit --no-fund
COPY . .
# Where the bundle points at the backend, fixed HERE rather than left to `.env`.
#
# Deployment platforms write their own `.env` into the source directory before
# building — Dokploy does — which overwrites the committed one and takes
# VITE_API_BASE with it. The build then fell through to a same-origin path and
# the console called `https://<its own domain>/fiesta/live/api/...`. A build
# argument outranks the file, so the host survives that overwrite.
#
# Override per environment with `--build-arg VITE_API_BASE=...` (Dokploy: Build
# Args), e.g. to point a staging console at a staging Fiesta.
ARG VITE_API_BASE="https://fiesta.nearle.app"
ENV VITE_API_BASE=$VITE_API_BASE
# There is no workspace flag in this image.
#
# This repository IS the Nearle platform console — every route it mounts is the
# platform workspace and only Nearle staff can sign in. The merchant console is
# a separate repository with its own deploy. A flag here would exist only as a
# way to deploy this application as something it is not.
#
# The merchant console's address, for the sentence shown to a merchant 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_MERCHANT_HOST="app.nearledaily.com"
ENV VITE_MERCHANT_HOST=$VITE_MERCHANT_HOST
RUN npm run build
# Stage 2 — serve
FROM nginx:alpine
# The config is a TEMPLATE, and that is deliberate.
#
# nginx:alpine's entrypoint runs `envsubst` over /etc/nginx/templates/*.template
# at container start and writes the result into conf.d. That is how the ingest
# API key reaches nginx as a runtime environment variable rather than being
# committed here in plain text — which is what the previous Dockerfile did with
# the Hasura secret, on the line this replaces.
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
# Restricts substitution to this one name.
#
# Without the filter, envsubst replaces every `${...}` it recognises as an
# environment variable — and the container's environment carries HOSTNAME, PATH
# and friends. Nginx's own `$uri`, `$remote_addr` and `$proxy_add_x_forwarded_for`
# would survive that today, but only by luck, and a config silently rewritten at
# boot is a bad thing to leave to luck.
ENV NGINX_ENVSUBST_FILTER="(INGEST_TOKEN|INGEST_UPSTREAM)"
# Empty by default — and this line is LOAD-BEARING. Do not delete it.
#
# BuildKit warns about it: `SecretsUsedInArgOrEnv: Do not use ARG or ENV
# instructions for sensitive data (ENV "INGEST_TOKEN")`. The warning is right in
# general and wrong here: nothing sensitive is baked in, the value is the empty
# string, and the real token is supplied at RUN time by the deployment.
#
# Removing the line to silence the warning breaks the config in a way that is
# hard to see. nginx's entrypoint builds its substitution list from env vars
# that are DEFINED:
#
# defined_envs=$(printf '${%s} ' $(awk "END { for (name in ENVIRON) ... }"))
#
# With INGEST_TOKEN undefined, it is not in that list, envsubst leaves the
# placeholder alone, and nginx ends up with the literal text `${INGEST_TOKEN}`
# as the token — which is not empty, so the missing-token guard never fires and
# every ingest call goes out with a nonsense `X-API-Key`.
#
# Declaring it empty here guarantees envsubst always substitutes it, so an
# unset token is a real empty string and the guard can catch it.
ENV INGEST_TOKEN=""
# Where the ingest service is. Declared here for the same reason as the line
# above: envsubst only substitutes names that are DEFINED, so an undeclared
# INGEST_UPSTREAM would leave the literal text `${INGEST_UPSTREAM}` in the
# config as the proxy target, and nginx would fail to start with an error that
# names the variable rather than the omission.
#
# Defaults to the public host so an existing deployment behaves exactly as it
# did. Set it to the sibling container's internal address — e.g.
# `http://mcp-backend:8000` — to take the private path and drop the credential
# entirely.
ENV INGEST_UPSTREAM="https://mcp.nearle.ai.in"
COPY --from=builder /app/dist/ /usr/share/nginx/html/
EXPOSE 80 3000
CMD ["nginx", "-g", "daemon off;"]

145
README.md Normal file
View File

@@ -0,0 +1,145 @@
# Nearle Platform Console
Nearle's own console, used by Nearle staff at **platform.nearledaily.com**:
the tenant directory, onboarding, the global catalogue, delivery partners and
platform-wide dispatch. Built against the existing Fiesta backend — the API is a
fixed constraint, not something this repo changes.
## This is not the merchant console
Merchants and their branch users sign in at **app.nearledaily.com**, which is a
separate repository (`nearle-console`) with its own deploy. Only `nearle-admin`
accounts can sign in here; a merchant account is refused with a sentence naming
where it belongs, and the refusal happens before any session is written.
The two were one application with three workspaces behind a role guard, and were
split so an internal tool and a customer-facing product could move at their own
pace — and so a change made for staff could not reach a shop.
**They share no code at runtime, and roughly thirty thousand lines are duplicated
between them**: the API layer, the query cache, the component library, the
drawers. That is a deliberate trade, taken because this side is internal — a
divergence here is something the team notices in its own tool rather than
something a merchant discovers. A fix worth having in both has to be made twice,
on purpose.
Folders named `store-admin` remain under `src/features/`. They are not merchant
screens: they are the shared pieces this console depends on — the dispatch board,
the drawer kit, formatting, assignment logic — still carrying the name they had
before the split.
## Running it
```bash
npm install
npm run dev # http://localhost:3100
npm run typecheck # tsc --noEmit
npm run build # typecheck + production build
```
> **Port 3100, not 3000.** The old console (`daily_merchant_web`) runs its dev
> server on 3000. `strictPort` is on, so if 3100 is taken this fails loudly
> rather than silently moving — which is the failure that makes you think your
> changes did not land when you are actually looking at a different app.
In development Vite proxies `/fiesta` → `https://fiesta.nearle.app`. For a
deployed build set `VITE_API_BASE` (see `.env.example`).
## Stack
| Layer | Choice |
|---|---|
| Build | Vite 8 |
| Language | **TypeScript 7**, strict, `noUncheckedIndexedAccess`. No `.js`/`.jsx` anywhere. |
| UI | React 19 + **@astryxdesign/core** |
| Theme | `src/theme/nearle.ts`, compiled to `nearle.css` |
| Routing | React Router 7, split per page |
| Server state | TanStack Query 5 |
| Icons | lucide-react |
| Spreadsheets | `xlsx`, loaded on demand |
## What's built
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 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
**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
These are not bugs in this repo. They are the API's behaviour, and each one is
worked around deliberately.
1. **No web authentication.** The login endpoints return the user record and no
token; only `/v1/pos/*` has middleware. So the session *is* that record, held
in `sessionStorage`. `src/auth/session.ts` is the only file that changes when
tokens arrive.
2. **Every list call needs a scoping id or it 400s.** The IDOR pass added
controller-level guards. Query hooks are therefore `enabled`-gated on the id
and the id is part of the query key.
3. **Roles are derived, not asserted.** `issuperadmin` is checked before
`roleid`, because the flag is server-derived and a roleid is not. Roleids 7
and 8 are till roles and never reach an admin workspace.
4. **`POST /products/create` takes one product and returns no id.** The
controller passes the struct to the service by value, so GORM writes the
generated id into a copy that is then discarded. The sheet importer therefore
creates, then re-queries by SKU to resolve ids, then batches the location and
stock writes. See `importSheetProducts` in `src/api/products.ts`.
5. **The two import paths are not symmetric.** Catalogue import is one
idempotent batch call. Sheet import is N creates with no dedupe on SKU, so
re-uploading a file duplicates its products — the UI says so before the
button.
6. **The global catalogue has a price *range*, not a price**, and no mapping to
a tenant's own categories. Category, subcategory, retail price, cost and tax
are collected before an import can be enabled.
7. **POS endpoints take one required `locationid`.** There is no tenant-wide
counter-sales call, so a multi-branch POS view fans out per branch.
## The 30-second cadence
Online orders land when an order is placed; counter sales reach the console on a
30-second refetch; anything still sitting on an offline till has not arrived at
all. So **any figure blending the two is eventually consistent**, and the
`<Freshness>` component exists to say when a number was last true and how many
bills are still stranded. Polling is applied per query in `src/queries/hooks.ts`
rather than globally — a provisioning form has no business re-polling.
## Design
The visual system is ported from KROW (which is built on Astryx too, so the
tokens are role-for-role comparable), with Nearle purple `#662582` as the accent.
The ambient canvas in `index.css` — a fixed, viewport-wide horizontal gradient at
`z-index: -1` — is the one piece of KROW that carries most of the character.
Radius, type scale and control heights are matched to KROW in
`src/theme/nearle.ts`, with the reasoning kept in comments.
**Open:** the dark-mode accent `#B57FD0` was chosen for 5.41:1 contrast on
Astryx's dark surface and still needs brand-owner sign-off before dark mode
ships.

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<meta name="theme-color" content="#662582" />
<meta name="description" content="Nearle Daily — retail operations console." />
<title>Nearle Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

183
nginx.conf.template Normal file
View File

@@ -0,0 +1,183 @@
# Nginx for the deployed console.
#
# A `.template`, not a plain conf: the nginx:alpine entrypoint runs `envsubst`
# over everything in /etc/nginx/templates and writes the result into conf.d at
# container start. That is what lets the ingest API key arrive as a runtime
# environment variable instead of being committed to this repository.
#
# ── Why this file exists in this shape ───────────────────────────────────────
#
# The previous version was inherited from the old console and proxied `/hasura/`
# — a path this console never calls — while having no block for `/fiesta/` at
# all. Every API call therefore fell through to `try_files … /index.html`, and
# nginx answers a POST to a static file with **405 Method Not Allowed** and an
# HTML body. The console reported "Malformed response (HTTP 405)", which was
# accurate and pointed nowhere near the cause: sign-in was never reaching the
# backend.
#
# The rule this file follows: every prefix the Vite dev server proxies must have
# a matching block here. `vite.config.ts` is the other half of this file, and
# the two drift apart silently — it works on every developer machine and fails
# only once deployed.
server {
listen 80;
listen 3000;
server_name _;
# 10 MB is the ingest service's own file limit, so anything larger is going
# to be refused anyway — but nginx's default is 1 MB, and it rejects the
# upload itself with a 413 before the request ever leaves this container.
# A merchant's product sheet passes 1 MB easily.
client_max_body_size 12m;
# ── The app ──────────────────────────────────────────────────────────────
location / {
root /usr/share/nginx/html;
index index.html;
# React Router owns the paths, so an unknown one is a route, not a 404.
try_files $uri $uri/ /index.html;
# index.html must be revalidated on every visit, and until now it was
# not — the line below is new, and its absence was a real outage.
#
# The block above said "index.html must NOT be cached" and then set no
# cache header at all, which is not the same thing. With neither
# `Cache-Control` nor `Expires`, a browser falls back to HEURISTIC
# caching: RFC 9111 lets it invent a freshness lifetime from
# `Last-Modified`, commonly a tenth of the document's age, and serve the
# document from disk WITHOUT revalidating. So a tab kept the previous
# index.html, that index.html named `InventoryPage-BAzj-ICF.js`, the
# deploy had replaced it with `InventoryPage-Cbh53mHU.js`, and the
# import 404'd on a screen that had worked ten minutes earlier.
#
# `no-cache` does NOT mean "do not store" — it means "revalidate before
# use". The ETag still answers 304 on an unchanged deploy, so this costs
# one conditional request per visit and never a re-download.
add_header Cache-Control "no-cache" always;
}
# Hashed filenames, so these can be cached hard — the hash changes when the
# content does, which is what makes a year safe.
location /assets/ {
root /usr/share/nginx/html;
# ONE header, not two. `expires 1y` emits its own
# `Cache-Control: max-age=31536000`, and the `add_header` beside it
# appended a second, so every asset went out with two conflicting
# `Cache-Control` lines — `max-age=31536000` and `public, immutable`,
# neither complete. Browsers mostly cope; caches and CDNs in between are
# entitled to take the first and drop `immutable`, or to treat the pair
# as malformed. Merged into a single directive, with `expires` dropped
# because it exists only to emit the header this now sets by hand.
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# ── Fiesta ───────────────────────────────────────────────────────────────
#
# Mirrors the dev proxy exactly: `/fiesta/live/api/...` → `/live/api/...` on
# fiesta.nearle.app. The trailing slash on proxy_pass is what strips the
# prefix — without it the upstream receives `/fiesta/live/...` and 404s.
#
# Proxied rather than called directly from the browser so the API stays
# same-origin. That keeps CORS out of the picture and means the deployed
# console and a developer's machine take the same code path.
location /fiesta/ {
proxy_pass https://fiesta.nearle.app/;
proxy_ssl_server_name on;
proxy_set_header Host fiesta.nearle.app;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
# ── Catalogue ingest ─────────────────────────────────────────────────────
#
# The API key is attached HERE, by nginx, from an environment variable set
# on the container. It never reaches the browser — which matters more than
# usual for this one: the key carries `require_admin` on that service, which
# is a superuser, so the same key also reaches /api/catalog/generate and
# /api/system/init. A key compiled into the JavaScript bundle is a key
# handed to every visitor.
#
# This also settles CORS. The owning team's allow-list does not include this
# console's origin and should not need to: the browser only ever talks to
# its own host, and this container makes the cross-origin call.
location /ingest/ {
# ── Where the ingest service is, and how we prove who we are ─────────
#
# Both are environment variables so the deployment can move between two
# arrangements without a rebuild:
#
# INGEST_UPSTREAM=https://mcp.nearle.ai.in INGEST_TOKEN=<secret>
# The public host. Needs a credential, because that host is on the
# internet and its guards do not care who is asking.
#
# INGEST_UPSTREAM=http://<container>:<port> INGEST_TOKEN=
# The internal Docker network. `app.nearledaily.com` and
# `mcp.nearle.ai.in` both resolve to 72.60.218.25 — the same host —
# so the two containers can talk without going out to the internet
# and back. No secret has to exist on this side at all, which is
# the whole point: a credential that is never issued cannot leak,
# expire, or be pasted into a chat window.
#
# The second needs the ingest service to trust its own machine. That is
# their change, not ours; this side is ready for either.
set $ingest_token "${INGEST_TOKEN}";
# No 503 guard for a missing token any more, deliberately.
#
# It existed because an unset token sent no header and the service
# answered with the same flat 401 it gives a wrong key. Two problems,
# one message. It cannot stay: on the internal network an empty token is
# the CORRECT configuration, and a guard that refuses the intended setup
# is worse than the ambiguity it was written to remove. The service's own
# 401 now names the fix — "Send a bearer token ... or an X-API-Key
# header" — which is the sentence the guard was standing in for.
#
# nginx omits a header whose value is empty, so the line below sends
# `X-API-Key` on the public host and nothing at all internally. One
# directive, both modes, no branching.
# `${INGEST_UPSTREAM}` and not `$upstream_variable`, and the difference
# is not cosmetic.
#
# envsubst rewrites this line at container start, so nginx parses a
# literal address and behaves exactly as it did when the host was
# hardcoded. Putting an nginx VARIABLE in proxy_pass instead changes
# three things at once: nginx resolves the name per request rather than
# at startup, which requires a `resolver` directive; 127.0.0.11 (Docker's
# embedded DNS) exists only on a user-defined network, so on a default
# bridge every ingest call fails with "recv() failed ... while resolving";
# and a variable proxy_pass stops stripping the location prefix, so the
# upstream starts receiving `/ingest/api/...` and 404s. Measured, not
# guessed — the first version of this did all three.
#
# The trailing slash is what strips `/ingest/`, so INGEST_UPSTREAM must
# NOT end in one. A name that cannot be resolved now fails at startup
# rather than per request, which is the better place to find out.
proxy_pass ${INGEST_UPSTREAM}/;
proxy_ssl_server_name on;
# Derived from the upstream rather than hardcoded, so it stays correct
# when the upstream becomes a container name.
proxy_set_header Host $proxy_host;
proxy_set_header X-API-Key $ingest_token;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
# Ingest submits return 202 immediately, but a sheet near the size limit
# takes a moment to upload and the service can be slow to accept it.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Send the upload straight through rather than spooling it to disk
# first — nginx would otherwise buffer the whole workbook before the
# upstream saw a byte.
proxy_request_buffering off;
}
# The `/hasura/` block that used to be here is gone. It belonged to the old
# console (daily_merchant_web) and nothing in this app has ever called it —
# it also carried a Hasura admin secret hardcoded in plain text, committed
# to the repository. That secret should be rotated.
}

5160
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "nearle-platform",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port 3100 --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3100",
"typecheck": "tsc --noEmit",
"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",
"check:health": "tsx scripts/checkHealthPanel.ts",
"check:optimiser": "tsx scripts/checkOptimiser.ts",
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"",
"appsweep": "node scripts/appsweep.mjs",
"appfix": "node scripts/appfix.mjs",
"preview:map": "node scripts/mapPreview.mjs"
},
"dependencies": {
"@astryxdesign/core": "^0.4.5",
"@stylexjs/stylex": "^0.19.0",
"@tanstack/react-query": "^5.101.4",
"leaflet": "^1.9.4",
"lucide-react": "^1.33.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"xlsx": "^0.18.5"
},
"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"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

BIN
public/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/logo-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/logo-wordmark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

262
scripts/appfix.mjs Normal file
View File

@@ -0,0 +1,262 @@
/**
* Repairs the products the customer app cannot show.
*
* node scripts/appfix.mjs # dry run — prints, writes nothing
* node scripts/appfix.mjs --apply # performs the repair
* node scripts/appfix.mjs --apply --tenant 1135
*
* `appsweep.mjs` finds them; this puts them right. Same detection, so the two
* cannot disagree about what is broken.
*
* ── What it does, and why it is shaped like this ─────────────────────────────
*
* The repair is "give the product a category", and for a long time there was no
* way to do it. `products/update` writes only `productlocations.status` despite
* its name, and `importcatalogueproduct` took an existing product down a branch
* that corrected the PRICE and left the category alone — so re-importing, the
* obvious fix, appeared to work and changed nothing.
*
* That branch now also calls `UpdateProductCategory`
* (`services/productService.go`), which makes re-import the repair path. This
* script drives it: for each orphan it re-sends the original import with a real
* `categoryid`.
*
* REQUIRES THE FIXED BACKEND. Against the currently deployed one every call
* returns 200 and nothing changes, which is exactly the failure that makes this
* bug expensive — so the script verifies each product afterwards and reports
* what actually moved rather than what it asked for.
*
* ── What it deliberately does not do ─────────────────────────────────────────
*
* It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is
* written — `CreateProductLocation` only records stock when quantity > 0. The
* products already have their stock and this must not add to it.
*
* It re-sends each product's EXISTING price, cost and tax, because the same
* call updates pricing. Sending zeros would wipe the prices while fixing the
* category.
*/
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
const WEB = `${BASE}/live/api/v1/web`;
const apply = process.argv.includes('--apply');
const tenantArg = process.argv.indexOf('--tenant');
const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null;
async function get(path, params = {}) {
const query = new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== ''),
);
const response = await fetch(`${WEB}${path}?${query}`, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`);
const payload = await response.json().catch(() => null);
return payload?.details ?? payload?.data ?? null;
}
async function post(path, body) {
const response = await fetch(`${WEB}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { ok: response.ok && payload?.status !== false, status: response.status, payload };
}
async function tenantsWithOrphans() {
const seen = new Map();
for (let page = 1; page <= 20; page++) {
const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? [];
const list = Array.isArray(rows) ? rows : [];
for (const t of list) if (t?.tenantid) seen.set(t.tenantid, t);
if (list.length < 200) break;
}
const out = [];
for (const tenant of seen.values()) {
if (onlyTenant && tenant.tenantid !== onlyTenant) continue;
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
const orphans = products.filter((p) => !p.categoryid);
if (orphans.length === 0) continue;
/**
* Where each orphan already sits.
*
* `getallproducts` is tenant-wide and carries NO locationid — the first
* version of this read `p.locationid` off it, got undefined, sent 0, and
* every repair came back "missing required field(s): locationid". Nothing
* was written, which is the one good thing about that failure.
*
* The outlet matters beyond passing validation. Import writes a
* productlocations row, so naming an outlet the product is NOT on would put
* it on that shelf — silently extending the product's reach as a side
* effect of a repair. Only an outlet where it is already stocked is safe:
* there the upsert lands on the existing row.
*/
const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? [];
const placement = new Map();
for (const loc of Array.isArray(locations) ? locations : []) {
const rows = (await get('/products/getlocationproducts', {
tenantid: tenant.tenantid,
locationid: loc.locationid,
pageno: 1,
pagesize: 500,
})) ?? [];
for (const row of Array.isArray(rows) ? rows : []) {
if (!placement.has(row.productid)) placement.set(row.productid, { loc, row });
}
}
out.push({ tenant, orphans, placement });
}
return out;
}
/**
* The category to file a product under.
*
* `gettenantcategories` is synthesised from the categories the tenant's own
* products already use, so it is the tenant's real answer rather than the
* master table's — which is unscoped and, for the tenants seen here, offered a
* category (1001) that the app does not browse.
*
* Refusing rather than guessing when the tenant has none: a wrong category is
* findable and fixable, but writing one at random across a live catalogue is
* not something a repair script should decide.
*/
async function categoryFor(tenantid) {
const rows = (await get('/products/gettenantcategories', { tenantid })) ?? [];
const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0);
return usable[0]?.categoryid ?? null;
}
const work = await tenantsWithOrphans();
if (work.length === 0) {
console.log('Nothing to repair — no product is missing a category.');
process.exit(0);
}
console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n');
let repaired = 0;
let unchanged = 0;
let refused = 0;
for (const { tenant, orphans, placement } of work) {
const categoryid = await categoryFor(tenant.tenantid);
console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`);
if (!categoryid) {
console.log(' SKIPPED: this tenant has no category of its own to file into.\n');
refused += orphans.length;
continue;
}
for (const p of orphans) {
const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`;
if (!p.productbrand || !p.catalogueid) {
// Not imported from the global catalogue, so brand+catalogueid cannot
// address it and re-import is not available. Says so rather than
// reporting a success it did not achieve.
console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`);
refused++;
continue;
}
const at = placement.get(p.productid);
if (!at) {
console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`);
refused++;
continue;
}
/**
* A product with no price must not be made visible.
*
* Repairing the category is what puts a product in front of shoppers, and
* the app has no price floor — `GetProducts` filters on category and outlet
* and nothing else. Fixing a product priced at 0 would not "restore" it; it
* would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly
* this state, priced nowhere, and it wants a price before it wants a
* category.
*/
const price = Number(p.retailprice ?? 0);
if (!(price > 0)) {
console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`);
refused++;
continue;
}
if (!apply) {
console.log(
` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`,
);
continue;
}
const result = await post('/products/importcatalogueproduct', [
{
tenantid: tenant.tenantid,
// An outlet the product ALREADY sits at, so the upsert lands on the
// existing row instead of putting it on a new shelf. The category
// itself is written to `products`, which is tenant-wide, so one call
// fixes the product everywhere.
locationid: at.loc.locationid,
brand: p.productbrand,
catalogueid: p.catalogueid,
categoryid,
subcategoryid: p.subcategoryid ?? 0,
// Zero, so CreateProductLocation writes no stock ledger entry — it only
// records stock when quantity > 0. These products already have their
// stock and a repair must not add to it.
quantity: 0,
stocktype: 'in',
status: at.row.productstatus || p.productstatus || 'Active',
// The product's OWN current values, re-sent unchanged. The same call
// updates pricing, and the location upsert sets productlocations.price
// from retailprice — verified equal for every product being repaired
// here, so this round-trips rather than overwriting an outlet price.
retailprice: price,
productcost: p.productcost ?? 0,
taxpercent: p.taxpercent ?? 0,
},
]);
if (!result.ok) {
console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`);
refused++;
continue;
}
// Verified, not assumed. The whole reason this bug survived is that the
// call that was supposed to fix it returned success and did nothing.
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
const after = (Array.isArray(groups) ? groups : [])
.flatMap((g) => g?.products ?? [])
.find((x) => x.productid === p.productid);
if (after?.categoryid > 0) {
console.log(` ${label} → categoryid ${after.categoryid} ✓`);
repaired++;
} else {
console.log(
` ${label} — NO CHANGE: the call succeeded but the category is still 0.` +
' The backend fix is not deployed.',
);
unchanged++;
}
}
console.log();
}
console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`);
if (unchanged > 0) {
console.log(
'\nProducts reported NO CHANGE need the backend fix deployed' +
' (services/productService.go — re-import must call UpdateProductCategory).',
);
process.exit(1);
}

193
scripts/appgap.mjs Normal file
View File

@@ -0,0 +1,193 @@
/**
* Why the customer app shows fewer products than the console does.
*
* node scripts/appgap.mjs <tenantid> <locationid>
* npm run appgap 1135 1166
*
* Straight at Fiesta — no Hasura, no admin secret, nothing to configure. The
* first version of this went at the database through Hasura, which was the
* wrong instrument twice over: it needed a secret, it 404'd on an admin API
* that is often disabled, and it answered a question about the DATABASE when
* the question is about what the API returns. This calls the very endpoint the
* app calls and compares it with what the console can see.
*
* ── The three filters ────────────────────────────────────────────────────────
*
* `getproductsbysubcategory` drops a product for one of three reasons, and none
* of them is an error, logged or visible from either end:
*
* A. `WHERE a.categoryid = 2` — not optional (`productRepository.go:865`).
* A product in another category cannot appear, whatever else is true.
*
* B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. No row
* for this outlet means the join yields NULL and the WHERE drops it. Being
* in the catalogue is not the same as being on a shelf.
*
* C. The grouping in `GetProductsBySubcategory` collects products under each
* real subcategory of category 2, then sweeps up `subcategoryid = 0` as
* "Uncategorized". A subcategoryid that is non-zero and NOT a subcategory
* of 2 matches neither and vanishes — present in the SQL, absent from the
* JSON.
*/
/**
* Through the console's own host, not Fiesta directly.
*
* `app.nearledaily.com/fiesta/...` is the nginx proxy the deployed console
* already uses, so this script exercises exactly the path the browser takes —
* if the proxy is misconfigured this finds out, where hitting fiesta.nearle.app
* would quietly work and prove nothing about production.
*
* Override with FIESTA_URL to point at the backend directly or at a dev server.
*/
const BASE = process.env.FIESTA_URL ?? 'https://app.nearledaily.com/fiesta';
const WEB = `${BASE}/live/api/v1/web`;
const MOB = `${BASE}/live/api/v1/mob`;
const [, , tenantArg, locationArg] = process.argv;
const tenantid = Number(tenantArg);
const locationid = Number(locationArg);
if (!tenantid || !locationid) {
console.error('Usage: node scripts/appgap.mjs <tenantid> <locationid>');
process.exit(1);
}
/** Fiesta answers under `details` in most places and `data` in a few. */
async function get(url, params) {
const query = new URLSearchParams(
Object.entries(params).filter(([, value]) => value !== undefined && value !== ''),
);
let response;
try {
response = await fetch(`${url}?${query}`, { headers: { Accept: 'application/json' } });
} catch (cause) {
console.error(`Could not reach ${BASE} — ${cause.message}`);
process.exit(1);
}
const payload = await response.json().catch(() => null);
if (!payload) {
console.error(`Malformed response from ${url} (HTTP ${response.status})`);
process.exit(1);
}
return payload.details ?? payload.data ?? null;
}
/**
* Every product the tenant owns.
*
* `getallproducts` answers `[]models.Tenantproducts` — `{tenant, products}`
* groups, not a flat list — and under `data` rather than `details`.
*/
async function allProducts() {
const groups = await get(`${WEB}/products/getallproducts`, { tenantid });
if (!Array.isArray(groups)) return [];
return groups.flatMap((group) => group?.products ?? []);
}
/**
* What is actually listed at this outlet.
*
* Paged, and the page size matters: the default is 50, so a shop with 200
* products would look like one with 50 and every product past the first page
* would be miscounted as "not listed". Walked until a short page comes back.
*/
async function locationProducts() {
const rows = [];
const pagesize = 200;
for (let pageno = 1; pageno <= 50; pageno += 1) {
const page = await get(`${WEB}/products/getlocationproducts`, {
tenantid,
locationid,
pageno,
pagesize,
});
const batch = Array.isArray(page) ? page : [];
rows.push(...batch);
if (batch.length < pagesize) break;
}
return rows;
}
/** Exactly what the app asks for, so the comparison is against reality. */
async function appView() {
const payload = await get(`${MOB}/products/getproductsbysubcategory`, {
categoryid: 2,
tenantid,
locationid,
});
const details = payload?.details ?? (Array.isArray(payload) ? payload : []);
return Array.isArray(details) ? details : [];
}
const [products, listedRows, groups, subcategories] = await Promise.all([
allProducts(),
locationProducts(),
appView(),
get(`${WEB}/products/getproductsubcategories`, { tenantid, categoryid: 2 }),
]);
if (products.length === 0) {
console.log(`Tenant ${tenantid} has no products at all — nothing for the app to show.`);
process.exit(0);
}
const listed = new Set(listedRows.map((row) => row.productid));
/**
* `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`.
*
* This read the long names — the ones `getproductsubcategories` does NOT send —
* so every entry was `undefined → undefined`, the map collapsed to a single
* junk key, and check C below could never match. The footer duly announced
* "(none returned)" for tenant 1147, whose category 2 has six subcategories.
* A diagnostic that is confidently wrong is worse than one that is missing.
*/
const realSubs = new Map(
(Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]),
);
const inApp = new Set();
for (const group of groups) {
for (const product of group.products ?? []) inApp.add(product.productid);
}
const buckets = new Map();
const examples = new Map();
for (const product of products) {
let reason;
if (inApp.has(product.productid)) {
reason = 'OK — the app shows this';
} else if (product.categoryid !== 2) {
reason = `A — categoryid is ${product.categoryid}, the app only asks for 2`;
} else if (!listed.has(product.productid)) {
reason = 'B — not listed at this outlet (no productlocations row)';
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
reason = `C — subcategoryid ${product.subcategoryid} is not a subcategory of 2, so it is dropped`;
} else {
// Everything checks out and it still is not there. Worth its own bucket
// rather than being folded into one of the above: a wrong guess here would
// send someone fixing data that is already correct.
reason = '? — passes all three checks but the app still does not return it';
}
const key = reason.replace(/\d+/g, 'N');
buckets.set(key, (buckets.get(key) ?? 0) + 1);
if (!examples.has(key)) examples.set(key, { product, reason });
}
console.log(`Tenant ${tenantid}, outlet ${locationid}`);
console.log(` ${products.length} products in the catalogue`);
console.log(` ${listed.size} listed at this outlet`);
console.log(` ${inApp.size} returned by the app's endpoint\n`);
for (const [key, count] of [...buckets.entries()].sort((a, b) => b[1] - a[1])) {
const { product, reason } = examples.get(key);
console.log(` ${String(count).padStart(5)} ${reason}`);
console.log(
` e.g. "${product.productname}" — id ${product.productid}, category ${product.categoryid}, subcategory ${product.subcategoryid}`,
);
}
console.log(
`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none returned)'}`,
);

246
scripts/appsweep.mjs Normal file
View File

@@ -0,0 +1,246 @@
/**
* Every product on the platform the customer app cannot show, and why.
*
* node scripts/appsweep.mjs
* node scripts/appsweep.mjs --json > sweep.json
*
* `appgap.mjs` answers this for ONE outlet and is the tool to reach for when
* somebody reports a specific shop. This is the platform-wide version: it walks
* every tenant, in every approval state, and reports the products that are
* unreachable no matter what the app asks for.
*
* ── The rule it applies ──────────────────────────────────────────────────────
*
* Read off the backend rather than inferred from responses
* (`controllers/productController.go:431`, `repositories/productRepository.go:858`):
*
* 1. `GetProductsBySubcategory` REJECTS `categoryid = 0` with a 400 —
* "Valid categoryid is required". It is the first thing the controller
* does.
* 2. The query then filters `WHERE a.categoryid = ?` unconditionally.
*
* Together those mean a product stored with `categoryid = 0` is returned for NO
* request the app can make. Not "usually hidden" — unreachable. It is still
* listed by `getlocationproducts`, which does not filter on category, so the
* console shows it and the shop believes it is on sale.
*
* Nothing else in that query gates visibility: there is no filter on
* `approved`, `publishedat`, `productstatus`, or stock level. A product with
* zero stock still appears. So `categoryid = 0` is the whole defect, and this
* script looks for exactly it.
*/
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
const WEB = `${BASE}/live/api/v1/web`;
const asJson = process.argv.includes('--json');
/** Fiesta answers under `details` in most places and `data` in a few. */
async function get(path, params = {}) {
const query = new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== ''),
);
const response = await fetch(`${WEB}${path}?${query}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`);
const payload = await response.json().catch(() => null);
return payload?.details ?? payload?.data ?? null;
}
/** Requests that never succeeded. A non-empty list invalidates the report. */
const failures = [];
/**
* Bounded concurrency, with retries, and failures that are never swallowed.
*
* The first version of this caught every error and substituted `null`. Run
* across 262 tenants at a concurrency of 8, enough requests were refused that
* it reported ONE affected tenant out of three known ones — and reported it as
* a clean result, with no indication anything had gone wrong. A sweep that
* fails silently is worse than no sweep: it is used to close the investigation.
*
* So: three attempts with a widening delay, and anything still failing is
* recorded and printed at the end as an explicit gap in coverage.
*/
async function mapLimit(items, limit, fn, label = 'request') {
const out = new Array(items.length);
let next = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
for (;;) {
const i = next++;
if (i >= items.length) return;
let lastError;
for (let attempt = 0; attempt < 3; attempt++) {
try {
out[i] = await fn(items[i], i);
lastError = null;
break;
} catch (cause) {
lastError = cause;
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
}
}
if (lastError) {
out[i] = null;
failures.push(`${label}[${i}]: ${lastError?.message ?? lastError}`);
}
}
}),
);
return out;
}
/**
* Every tenant, from two sources because neither is complete on its own.
*
* `getalltenants` paginates and its `pageno` is 1-BASED — page 0 returns an
* empty list rather than the first page, the same off-by-one that
* `getlocationproducts` has. It carries 262 tenants where the status lists
* carry 142.
*
* `/tenants/search` is still needed alongside it: it branches on the word
* "pending" and queries `approved = 0` instead of a status
* (`tenantRepository.go:45-77`), which is the only way to learn that a tenant
* is unapproved. Suriya Store is one.
*
* Building from the status lists ALONE was the first version's other bug: R
* mart (1147) appears in none of the three, and a sweep that cannot see a
* tenant reports it as having nothing wrong.
*/
async function allTenants() {
const seen = new Map();
for (let page = 1; page <= 20; page++) {
const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? [];
const list = Array.isArray(rows) ? rows : [];
for (const t of list) if (t?.tenantid && !seen.has(t.tenantid)) seen.set(t.tenantid, { ...t });
if (list.length < 200) break;
}
for (const status of ['Active', 'pending', 'InActive']) {
const rows = (await get('/tenants/search', { status })) ?? [];
for (const t of Array.isArray(rows) ? rows : []) {
if (!t?.tenantid) continue;
const existing = seen.get(t.tenantid) ?? { ...t };
seen.set(t.tenantid, { ...existing, approvalState: status });
}
}
return [...seen.values()];
}
const tenants = await allTenants();
if (!asJson) console.error(`Scanning ${tenants.length} tenants…`);
/* Pass one — the whole platform, one request per tenant.
`getallproducts` returns {tenant, products} groups, not a flat list. */
const scanned = await mapLimit(tenants, 8, async (tenant) => {
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
return { tenant, products, orphans: products.filter((p) => !p.categoryid) };
}, "tenant-products");
const affected = scanned.filter((row) => row && row.orphans.length > 0);
/* Pass two — only the tenants that failed, so the outlet detail costs nothing
on a clean platform. */
const detailed = await mapLimit(affected, 6, async (row) => {
const locations = (await get('/tenants/gettenantlocations', { tenantid: row.tenant.tenantid })) ?? [];
const branches = await mapLimit(Array.isArray(locations) ? locations : [], 4, async (loc) => {
const shelved = (await get('/products/getlocationproducts', {
tenantid: row.tenant.tenantid,
locationid: loc.locationid,
pageno: 1,
pagesize: 500,
})) ?? [];
const list = Array.isArray(shelved) ? shelved : [];
const hidden = list.filter((p) => !p.categoryid);
return {
locationid: loc.locationid,
locationname: loc.locationname,
shelved: list.length,
hidden: hidden.length,
// The number that matters to a shopper: an outlet whose entire range is
// invisible looks like a closed shop, not like a partial catalogue.
visible: list.length - hidden.length,
};
});
return { ...row, branches: branches.filter(Boolean) };
}, "tenant-branches");
/* Coverage is part of the result, not a footnote. A tenant whose products
never loaded is UNKNOWN, not clean, and the difference decides whether this
report can be used to say the platform is fixed. */
const unreached = scanned.filter((row) => !row).length;
if (asJson) {
console.log(
JSON.stringify(
{
scannedTenants: tenants.length,
affectedTenants: detailed.length,
orphanProducts: detailed.reduce((n, r) => n + r.orphans.length, 0),
tenants: detailed.map((r) => ({
tenantid: r.tenant.tenantid,
tenantname: r.tenant.tenantname,
approvalState: r.tenant.approvalState,
totalProducts: r.products.length,
orphans: r.orphans.map((p) => ({
productid: p.productid,
productname: p.productname,
productbrand: p.productbrand,
catalogueid: p.catalogueid,
retailprice: p.retailprice,
})),
branches: r.branches,
})),
},
null,
2,
),
);
} else {
const orphanCount = detailed.reduce((n, r) => n + r.orphans.length, 0);
const blindOutlets = detailed.flatMap((r) =>
r.branches.filter((b) => b.shelved > 0 && b.visible === 0),
);
console.log(`\n${tenants.length} tenants scanned`);
console.log(`${detailed.length} affected`);
console.log(`${orphanCount} products with categoryid 0 — invisible in the app`);
console.log(`${blindOutlets.length} outlets stocked but showing NOTHING to shoppers\n`);
if (unreached > 0 || failures.length > 0) {
console.log(
`!! ${unreached} tenants could not be read after 3 attempts — this report is INCOMPLETE
`,
);
for (const f of failures.slice(0, 10)) console.log(` ${f}`);
if (failures.length > 10) console.log(` … ${failures.length - 10} more
`);
console.log();
}
for (const row of detailed.sort((a, b) => b.orphans.length - a.orphans.length)) {
const { tenant } = row;
console.log(
`${tenant.tenantid} ${tenant.tenantname}` +
` — ${row.orphans.length}/${row.products.length} products hidden` +
(tenant.approvalState === 'pending' ? ' [unapproved]' : ''),
);
for (const p of row.orphans) {
console.log(` ${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`);
}
for (const b of row.branches) {
if (b.shelved === 0) continue;
const flag = b.visible === 0 ? ' ← app shows an EMPTY shop' : '';
console.log(
` · ${String(b.locationname ?? b.locationid).padEnd(28)}` +
` ${b.visible}/${b.shelved} visible${flag}`,
);
}
console.log();
}
}

View File

@@ -0,0 +1,91 @@
/**
* What the health panel would render, for real products, against the live
* service.
*
* Not a test — the tests pin behaviour against a frozen fixture. This runs the
* SAME functions the panel calls against whatever the service is returning
* right now, which is the only way to catch the service changing under us.
*
* npx tsx scripts/checkHealthPanel.ts
*/
import { nutritionApi, __resolveBrand } from '../src/api/nutrition';
import { facts, present, BAND_LABEL } from '../src/features/store-admin/healthScore';
const CASES: { label: string; brand: string; imageId: string; expect: string }[] = [
{
label: 'Cadbury 5 Star 200g',
brand: 'Cadbury',
imageId: 'cadbury_cadbury_5_star_200g',
expect: 'a score, with a low-confidence caveat',
},
{
label: 'Godrej Hit Spray (INSECTICIDE)',
brand: 'Godrej',
imageId: 'godrej_hit_spray_1101d017',
expect: 'NO score — blocked by the edibility guard',
},
{
label: 'Naga Sooji',
brand: 'Naga',
imageId: 'naga_naga_sooji_100g',
expect: 'a high score, allergen declared',
},
{
label: 'Aachi Baby Fryums 100g (a real merchant product)',
brand: 'Aachi',
imageId: 'aachi_aachi_baby_fryums_100g',
expect: 'known but unscored',
},
];
const line = (s = '') => console.log(s);
for (const testCase of CASES) {
line();
line('─'.repeat(72));
line(`${testCase.label}`);
line(`expected: ${testCase.expect}`);
line('─'.repeat(72));
const raw = await nutritionApi.forProduct(testCase.brand, testCase.imageId);
const shown = present(raw);
if (shown.isEmpty) {
line(' → "No health score available for this product yet."');
continue;
}
if (shown.isPending) {
line(' → "This product is in the catalogue but has not been scored yet."');
line(` (service sent health_score=${raw?.health_score}, category="${raw?.category}")`);
continue;
}
line(` SCORE ${shown.display}/100 ${shown.band ? BAND_LABEL[shown.band] : ''}`);
for (const good of shown.good) line(` ✓ ${good}`);
for (const caution of shown.cautions) line(` ! ${caution}`);
if (shown.tags.length) line(` TAGS ${shown.tags.join(' · ')}`);
if (shown.allergens.length) line(` ALLERGENS Contains ${shown.allergens.join(', ')}`);
else if (shown.allergensUnconfirmed) line(' ALLERGENS not confirmed — check the pack');
const rows = facts(raw);
if (rows.length) line(` PER 100g ${rows.map((r) => `${r.label} ${r.value}`).join(' · ')}`);
if (shown.caveat) line(` CAVEAT ${shown.caveat}`);
if (shown.source) line(` SOURCE ${shown.source.label}`);
}
line();
/* ── Brand vocabularies ──────────────────────────────────────────────────── */
/* Our catalogue writes snake_case, theirs writes Title Case with a separator
that is sometimes a space and sometimes a hyphen. A mismatch returns
health_score: null — indistinguishable from an unscored product — so this
checks the resolution rather than trusting it. */
line('─'.repeat(72));
line('Brand resolution: our spelling → theirs');
line('─'.repeat(72));
for (const ours of ['cadbury', 'coca_cola', 'brooke_bond', '24_mantra', 'colgate_palmolive', 'aachi']) {
const theirs = await __resolveBrand(ours);
line(` ${ours.padEnd(20)} → ${theirs}`);
}
line();

89
scripts/checkOptimiser.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* The route-plan chain, run against the live optimiser.
*
* Not a test — the unit tests pin `routePlan.ts` against a frozen fixture. This
* calls the real service with real order rows and pushes the answer through the
* same functions the drawer uses, which is the only way to notice the service
* changing shape under us.
*
* npm run check:optimiser
*/
import { optimiserApi } from '../src/api/optimiser';
import type { OrderRow, RiderInfo } from '../src/api/types';
import {
applyReconcile,
commitProblem,
dirtyRiders,
planFromSequence,
splitRoutable,
planKms,
reorderStops,
unplaced,
} from '../src/features/store-admin/routePlan';
const line = (s = '') => console.log(s);
/** Real Suriya Store geography: the RS Puram branch out to four drops. */
const ORDERS: OrderRow[] = [
{ orderheaderid: 1, orderid: 'N-1', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0284', deliverylong: '77.0120', deliverycustomer: 'Peelamedu' },
{ orderheaderid: 2, orderid: 'N-2', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0050', deliverylong: '76.9508', deliverycustomer: 'RS Puram' },
{ orderheaderid: 3, orderid: 'N-3', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '10.9877', deliverylong: '76.9620', deliverycustomer: 'Ukkadam' },
{ orderheaderid: 4, orderid: 'N-4', pickuplat: '11.0118', pickuplong: '76.9456', deliverylat: '11.0183', deliverylong: '76.9724', deliverycustomer: 'Gandhipuram' },
// No coordinates — must come back as unplaced rather than vanishing.
{ orderheaderid: 5, orderid: 'N-5', deliverycustomer: 'No location on file' },
] as OrderRow[];
const RIDER: RiderInfo = { userid: 9701, fullname: 'Meera Raj', contactno: '9000000001' };
line('─'.repeat(74));
line('1 · SEQUENCE — send in a deliberately bad order, see what comes back');
line('─'.repeat(74));
const { routable, unroutable } = splitRoutable(ORDERS);
const stops = await optimiserApi.sequence(routable);
let plan = planFromSequence(stops, RIDER);
for (const stop of plan.riders[0]?.orders ?? []) {
line(
` ${String(stop.step).padStart(2)} ${(stop.orderid ?? '').padEnd(5)} ` +
`${(stop.deliverycustomer ?? '').padEnd(22)} ` +
`${String(stop.previouskms ?? '').padStart(5)} km ` +
`cum ${String(stop.cumulativekms ?? '').padStart(5)} km ` +
`eta ${stop.eta ?? '-'}m actualkms ${stop.actualkms ?? '-'}`,
);
}
line(` total ${planKms(plan).toFixed(1)} km`);
const missed = [...unroutable, ...unplaced(routable, stops)];
line(` unplaced: ${missed.length ? missed.map((o) => o.orderid).join(', ') : 'none'}`);
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
line();
line('─'.repeat(74));
line('2 · EDIT — move the first stop to last, which breaks the step numbers');
line('─'.repeat(74));
plan = reorderStops(plan, RIDER.userid, 0, (plan.riders[0]?.orders.length ?? 1) - 1);
line(` dirty rounds: ${[...plan.dirty].join(', ')}`);
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')} <- out of order`);
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'NO'}`);
line(` reason: ${commitProblem(plan)}`);
line();
line('─'.repeat(74));
line('3 · RECONCILE — the service repairs the step numbers');
line('─'.repeat(74));
try {
const response = await optimiserApi.reconcile(dirtyRiders(plan));
plan = applyReconcile(plan, response);
line(` steps now: ${plan.riders[0]?.orders.map((s) => s.step).join(' → ')}`);
line(` dirty rounds: ${plan.dirty.size === 0 ? 'none' : [...plan.dirty].join(', ')}`);
line(` commit allowed? ${commitProblem(plan) === '' ? 'YES' : 'no — ' + commitProblem(plan)}`);
} catch (error) {
line(` reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
line(` commit still blocked? ${commitProblem(plan) !== '' ? 'YES — correct' : 'NO — WRONG'}`);
}
line();

434
scripts/contract.mjs Normal file
View File

@@ -0,0 +1,434 @@
/**
* The contract check.
*
* Signs in once, calls every endpoint the console reads, and reports what came
* back: the HTTP status, the envelope, whether `details` is an array or an
* object or null, how many rows, and — the part that matters — the keys on the
* first row against the keys `src/api/types.ts` says to expect.
*
* This exists because field-name drift is invisible until a real payload
* arrives, and it is the single most likely way connecting to the backend goes
* wrong. It has already happened once from fixtures alone: POS health returns
* `terminal_id` while the sales split returns `terminalid`, and an index
* signature on the type let the wrong one typecheck in silence.
*
* READ-ONLY. Nothing here writes. Every create, update and import is left to a
* person on a scratch tenant, because several of them are unscoped and one of
* them moves stock.
*
* Run:
* npm run contract (prompts for the password, hidden)
*
* Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither
* is ever written into this file — see the note beside EMAIL below.
*
* Plain JavaScript on purpose: it runs with the node you already have, with no
* install step and no TypeScript loader in the way.
*
* The credentials are read from the environment and never printed, logged or
* written to a file. Put them in front of the command rather than in a script,
* and they stay out of your shell history if your shell is configured for it.
*/
import { createInterface } from 'node:readline';
const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app';
/**
* `/web/pos`, not `/pos`.
*
* The `/v1/pos` group sits behind the terminal's session guard; the console's
* copies of the same reads are registered under `/v1/web/pos`. Sweeping the
* wrong one would report a surface the console never calls.
*/
const WEB = '/live/api/v1/web';
const POS = '/live/api/v1/web/pos';
const MOB = '/live/api/v1/mob';
/**
* The account to sweep with.
*
* The email defaults because it is not a secret. The PASSWORD is never
* defaulted and never written into this file: a password in source is
* committed, synced to every machine that clones the repo, and survives in the
* history after it is changed. It is read from the environment if set, and
* otherwise typed at the prompt below, where it is not echoed and does not
* reach the shell history.
*/
const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in';
/** Reads a line without echoing it. */
function askHidden(question) {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const onData = (char) => {
// Stop echoing everything except the newline that ends the answer.
if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') {
process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length));
}
};
process.stdin.on('data', onData);
rl.question(question, (answer) => {
process.stdin.off('data', onData);
rl.close();
process.stdout.write('\n');
resolve(answer);
});
});
}
const PASSWORD =
process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `));
if (!PASSWORD) {
console.error('No password given — nothing to sign in with.');
process.exit(1);
}
async function call(path, init = {}) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(init.params ?? {})) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const query = search.toString();
const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, {
method: init.method ?? 'GET',
headers: init.body
? { Accept: 'application/json', 'Content-Type': 'application/json' }
: { Accept: 'application/json' },
...(init.body ? { body: JSON.stringify(init.body) } : {}),
});
let envelope = {};
try {
envelope = await response.json();
} catch {
envelope = { message: 'not JSON' };
}
return { http: response.status, envelope };
}
/**
* A call that reports a dead host rather than crashing the run.
*
* A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should
* leave the other twenty-four results on screen — a stack trace at check four
* tells you nothing about checks five to twenty-five.
*/
async function attempt(path, init = {}) {
try {
return await call(path, init);
} catch (cause) {
return {
http: 0,
envelope: { status: false, message: `could not reach the server (${String(cause)})` },
};
}
}
/* ── Sign in ─────────────────────────────────────────────────────────────── */
const login = await attempt(`${WEB}/users/applogin`, {
method: 'POST',
// `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`.
body: { authname: EMAIL, password: PASSWORD, configid: 1 },
});
if (login.envelope.status !== true || !login.envelope.details) {
console.error(
`Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`,
);
process.exit(1);
}
const me = login.envelope.details;
const tenantid = Number(me['tenantid'] ?? 0);
const locationid = Number(me['locationid'] ?? 0);
const issuperadmin = me['issuperadmin'] === true;
console.log('── signed in ─────────────────────────────────────────────');
console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`);
console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`);
console.log('login keys:', Object.keys(me).sort().join(', '));
console.log('');
/**
* A tenant and a branch to probe the scoped endpoints with.
*
* A super admin has neither of their own, so one is borrowed from the platform
* list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one.
*/
let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid;
let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid;
if (!probeTenant) {
const tenants = await attempt(`${WEB}/tenants/getalltenants`, {
params: { pageno: 1, pagesize: 1 },
});
probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0);
}
if (probeTenant && !probeLocation) {
const locations = await attempt(`${WEB}/tenants/gettenantlocations`, {
params: { tenantid: probeTenant },
});
probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0);
}
console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`);
/* ── What we expect ──────────────────────────────────────────────────────── */
/**
* The keys each row should carry, taken from `src/api/types.ts`.
*
* Only the ones the console actually reads are listed — a backend that returns
* MORE than this is fine and normal, and is reported as extras rather than as a
* failure. What matters is anything missing.
*/
const CHECKS = [
{
name: 'tenants/getalltenants',
path: `${WEB}/tenants/getalltenants`,
params: { pageno: 1, pagesize: 5 },
expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'],
},
{
name: 'tenants/search?pending',
path: `${WEB}/tenants/search`,
params: { status: 'pending' },
expect: ['tenantid', 'tenantname'],
},
{
name: 'tenants/gettenantlocations',
path: `${WEB}/tenants/gettenantlocations`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['locationid', 'tenantid', 'locationname', 'status'],
},
{
name: 'utils/getappcategories',
path: `${WEB}/utils/getappcategories`,
expect: ['categoryid', 'categoryname'],
},
{
name: 'orders/getlocationsummary',
path: `${WEB}/orders/getlocationsummary`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'],
},
{
name: 'orders/getordersummary',
path: `${WEB}/orders/getordersummary`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['total', 'delivered', 'cancelled'],
},
{
name: 'catalogue/getbrands',
path: `${WEB}/catalogue/getbrands`,
expect: ['brand', 'product_count'],
},
{
name: 'catalogue/getproducts',
path: `${WEB}/catalogue/getproducts`,
params: { pageno: 1, pagesize: 5 },
expect: ['id', 'brand', 'product_name'],
},
{
name: 'products/getimportedcatalogueproducts',
path: `${WEB}/products/getimportedcatalogueproducts`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['brand', 'catalogueid'],
},
{
name: 'products/getproductcategories',
path: `${WEB}/products/getproductcategories`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['categoryid', 'categoryname'],
},
{
name: 'products/gettenantcategories',
path: `${WEB}/products/gettenantcategories`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['categoryid', 'categoryname'],
},
{
name: 'products/getlocationproducts',
path: `${WEB}/products/getlocationproducts`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'location',
expect: ['productid', 'productname', 'price', 'publishedat', 'status'],
},
{
name: 'products/getallproducts',
path: `${WEB}/products/getallproducts`,
params: { tenantid: probeTenant, pageno: 1, pagesize: 5 },
needs: 'tenant',
expect: ['productid', 'productname'],
},
{
name: 'products/getstockstatement',
path: `${WEB}/products/getstockstatement`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'location',
expect: ['productid', 'opening', 'credit', 'debit', 'closing'],
},
{
name: 'products/getstockrequests',
path: `${WEB}/products/getstockrequests`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'tenant',
expect: ['requestid', 'productid', 'qty', 'status'],
},
{
name: 'products/getsaletemplate',
path: `${WEB}/products/getsaletemplate`,
params: { tenantid: probeTenant, locationid: probeLocation },
needs: 'tenant',
expect: ['tenantid', 'locations', 'products'],
},
{
name: 'customers/gettenantcustomers',
path: `${WEB}/customers/gettenantcustomers`,
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
needs: 'tenant',
expect: ['customerid', 'firstname', 'contactno'],
},
{
name: 'orders/getorders',
path: `${WEB}/orders/tenant/getorders`,
params: {
tenantid: probeTenant,
locationid: probeLocation,
fromdate: isoDaysAgo(30),
todate: isoDaysAgo(0),
pageno: 1,
pagesize: 5,
},
needs: 'tenant',
expect: ['orderheaderid', 'orderstatus'],
},
{
name: 'deliveries/getdeliveries',
path: `${WEB}/deliveries/getdeliveries`,
params: {
tenantid: probeTenant,
locationid: probeLocation,
fromdate: isoDaysAgo(30),
todate: isoDaysAgo(0),
pageno: 1,
pagesize: 5,
},
needs: 'tenant',
expect: ['orderheaderid', 'orderstatus'],
},
{
name: 'pos/sales/summary',
path: `${POS}/sales/summary`,
params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) },
needs: 'location',
expect: ['billcount', 'grosssales', 'taxcollected'],
},
{
name: 'pos/sales',
path: `${POS}/sales`,
params: { locationid: probeLocation, pageno: 0, pagesize: 5 },
needs: 'location',
expect: ['bills', 'total'],
},
{
name: 'pos/health/location',
path: `${POS}/health/location`,
params: { location_id: probeLocation },
needs: 'location',
expect: ['total', 'online', 'terminals'],
},
{
name: 'tenants/getposusers',
path: `${WEB}/tenants/getposusers`,
params: { tenantid: probeTenant, locationid: probeLocation },
needs: 'location',
expect: ['users', 'location_id'],
},
{
name: 'tenants/getstaffs (MOB)',
path: `${MOB}/tenants/getstaffs`,
params: { tenantid: probeTenant },
needs: 'tenant',
expect: ['userid', 'rolename', 'firstname'],
},
{ name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] },
{
name: 'tenants/getstaffshifts',
path: `${WEB}/tenants/getstaffshifts`,
params: { tenantid: probeTenant, locationid: probeLocation },
needs: 'location',
expect: ['shifts', 'location_id'],
},
];
function isoDaysAgo(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().slice(0, 10);
}
/* ── Run ─────────────────────────────────────────────────────────────────── */
let mismatches = 0;
let unreachable = 0;
for (const check of CHECKS) {
if (check.needs === 'tenant' && !probeTenant) {
console.log(`SKIP ${check.name} — no tenant to probe with`);
continue;
}
if (check.needs === 'location' && !probeLocation) {
console.log(`SKIP ${check.name} — no location to probe with`);
continue;
}
const { http, envelope } = await attempt(check.path, { params: check.params });
const payload = envelope.details ?? envelope.data;
const shape = Array.isArray(payload)
? `array(${payload.length})`
: payload === null || payload === undefined
? 'null'
: typeof payload;
// The row to inspect: the first element of a list, or the object itself.
const row = Array.isArray(payload) ? payload[0] : payload;
const keys = row && typeof row === 'object' ? Object.keys(row) : [];
const missing = check.expect.filter((key) => !keys.includes(key));
const ok = envelope.status !== false && http < 400 && missing.length === 0;
if (!ok) mismatches += 1;
if (http >= 400 || envelope.status === false) unreachable += 1;
console.log(
`${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`,
);
if (envelope.status === false || http >= 400) {
console.log(` message: ${envelope.message ?? '(none)'}`);
}
if (missing.length > 0 && keys.length > 0) {
console.log(` MISSING: ${missing.join(', ')}`);
console.log(` got: ${keys.sort().join(', ')}`);
}
if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) {
console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`);
}
}
console.log('');
console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`);
console.log(
mismatches === 0
? 'Every endpoint answered in the shape the console expects.'
: 'Anything marked CHECK either refused the call or is missing a key the console reads.',
);

399
scripts/db.mjs Normal file
View File

@@ -0,0 +1,399 @@
/**
* Direct database access, through Hasura.
*
* A scratchpad for reading and fixing rows that no screen exposes — setting a
* password on an account that was spawned without one, flipping a status,
* checking what the API is actually reading. It talks to the Hasura instance
* the old console proxies to (`api.workolik.com`), using the admin secret from
* `daily_merchant_web/.env`, which is gitignored and stays there.
*
* node scripts/db.mjs tables
* node scripts/db.mjs user care@nearle.in
* node scripts/db.mjs setpw care@nearle.in <password>
* node scripts/db.mjs sql "select userid, authname from app_users limit 5"
*
* The secret is read from disk or the environment and never printed, never
* written anywhere, and never passed on the command line.
*
* ── Read this before using `setpw` ────────────────────────────────────────
* This points at PRODUCTION. Every write here is immediate and unversioned.
* `setpw` refuses to run unless the account's password column is already
* empty, so it can only ever complete a setup that was never finished — it
* cannot overwrite a working login. Lift that guard only deliberately.
*
* Passwords in `app_users` are stored in clear. That is a property of this
* backend, not of this script; anything written here is readable by anyone
* with database access.
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
/**
* Where Hasura actually lives, discovered rather than assumed.
*
* The first version of this hardcoded `/v1/graphql` at the host root and got a
* 404. The old console's proxy is the clue it should have read: it rewrites
* `/hasura` to `/api/rest/`, which means Hasura is mounted under `/api`, not at
* the root. Rather than swap one guess for another, this tries the candidates
* and uses whichever answers.
*
* Override with HASURA_URL if it moves again — pass the full GraphQL URL.
*/
const ENDPOINT_CANDIDATES = process.env.HASURA_URL
? [process.env.HASURA_URL]
: [
'https://api.workolik.com/api/v1/graphql',
'https://api.workolik.com/v1/graphql',
'https://api.workolik.com/hasura/v1/graphql',
];
let ENDPOINT = ENDPOINT_CANDIDATES[0];
/** Finds the first candidate that answers a trivial query. */
async function resolveEndpoint() {
for (const candidate of ENDPOINT_CANDIDATES) {
try {
const response = await fetch(candidate, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
body: JSON.stringify({ query: '{ __typename }' }),
});
if (!response.ok) continue;
const payload = await response.json().catch(() => null);
if (payload && !payload.errors) {
ENDPOINT = candidate;
return candidate;
}
} catch {
// Next candidate.
}
}
console.error(
'Could not find the Hasura GraphQL endpoint. Tried:\n' +
ENDPOINT_CANDIDATES.map((c) => ` ${c}`).join('\n') +
'\nSet HASURA_URL to the full GraphQL URL and run again.',
);
process.exit(1);
}
/** Where the old console keeps its gitignored secret, relative to this repo. */
const ENV_CANDIDATES = [
resolve(HERE, '../../../nearle-daily/daily_merchant_web/.env'),
resolve(HERE, '../../daily_merchant_web/.env'),
'D:/nearle-daily/daily_merchant_web/.env',
];
function readSecret() {
if (process.env.HASURA_ADMIN_SECRET) return process.env.HASURA_ADMIN_SECRET;
for (const path of ENV_CANDIDATES) {
if (!existsSync(path)) continue;
const line = readFileSync(path, 'utf8')
.split(/\r?\n/)
.find((row) => row.startsWith('HASURA_ADMIN_SECRET='));
if (!line) continue;
const value = line.slice('HASURA_ADMIN_SECRET='.length).trim().replace(/^["']|["']$/g, '');
if (value) return value;
}
console.error(
'No admin secret found.\n' +
'Expected HASURA_ADMIN_SECRET in one of:\n' +
ENV_CANDIDATES.map((p) => ` ${p}`).join('\n') +
'\nor set it in the environment for this command.',
);
process.exit(1);
}
const SECRET = readSecret();
async function gql(query, variables = {}) {
let response;
try {
response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
body: JSON.stringify({ query, variables }),
});
} catch (cause) {
console.error(`Could not reach ${ENDPOINT} — ${cause.message}`);
process.exit(1);
}
const payload = await response.json().catch(() => null);
if (!payload) {
console.error(`Malformed response (HTTP ${response.status})`);
process.exit(1);
}
if (payload.errors) {
for (const error of payload.errors) console.error(`✗ ${error.message}`);
process.exit(1);
}
return payload.data;
}
/* ── Commands ─────────────────────────────────────────────────────────────── */
/** Every table Hasura has tracked. Start here if a query says "field not found". */
async function tables() {
const data = await gql(`{ __schema { queryType { fields { name } } } }`);
const names = data.__schema.queryType.fields
.map((field) => field.name)
.filter((name) => !name.endsWith('_aggregate') && !name.endsWith('_by_pk'))
.sort();
console.log(names.join('\n'));
console.log(`\n${names.length} tables`);
}
const USER_FIELDS = `userid authname firstname lastname contactno roleid status tenantid locationid configid`;
async function findUser(email) {
const data = await gql(
`query ($email: String!) {
app_users(where: { authname: { _eq: $email } }) { ${USER_FIELDS} password }
}`,
{ email },
);
return data.app_users ?? [];
}
async function user(email) {
const rows = await findUser(email);
if (rows.length === 0) {
console.log(`No account with authname "${email}".`);
return;
}
for (const row of rows) {
// The password itself is never printed — only whether one exists, which is
// the only thing anyone needs to know from here.
const { password, ...rest } = row;
console.log({ ...rest, haspassword: String(password ?? '').trim() !== '' });
}
}
/**
* Completes a password setup that was never finished.
*
* Refuses if a password is already set. An account that can sign in must not
* be changeable from a scratchpad — that is a support action with a person
* behind it, not a one-liner.
*/
async function setpw(email, password) {
if (!password || password.length < 6) {
console.error('Password must be at least 6 characters (the backend enforces this too).');
process.exit(1);
}
const rows = await findUser(email);
if (rows.length === 0) {
console.error(`No account with authname "${email}".`);
process.exit(1);
}
if (rows.length > 1) {
console.error(
`${rows.length} accounts share that email (configid ${rows.map((r) => r.configid).join(', ')}).\n` +
'Refusing to guess. Use `sql` with an explicit userid.',
);
process.exit(1);
}
const row = rows[0];
if (String(row.password ?? '').trim() !== '') {
console.error(
`userid ${row.userid} already has a password. This command only completes an unfinished setup.\n` +
'To reset a working login, do it deliberately with `sql`.',
);
process.exit(1);
}
if (row.roleid === 7 || row.roleid === 8) {
console.error(
`userid ${row.userid} is a till account (roleid ${row.roleid}). Those sign in at the terminal with a PIN, not here.`,
);
process.exit(1);
}
const data = await gql(
`mutation ($userid: Int!, $password: String!) {
update_app_users(where: { userid: { _eq: $userid } }, _set: { password: $password }) {
affected_rows
}
}`,
{ userid: row.userid, password },
);
const affected = data.update_app_users?.affected_rows ?? 0;
if (affected !== 1) {
console.error(`Expected to update 1 row, updated ${affected}. Nothing assumed — check manually.`);
process.exit(1);
}
console.log(
`✓ Password set on userid ${row.userid} (${email}), roleid ${row.roleid}, tenantid ${row.tenantid}.`,
);
console.log(' Sign in at the console with it now.');
}
/**
* Arbitrary read-only SQL, via Hasura's `run_sql`.
*
* Reads only. A statement that writes is refused here — writes go through a
* named command above, where they can carry their own guard.
*/
async function sql(statement) {
if (/^\s*(insert|update|delete|drop|alter|truncate|create)\b/i.test(statement)) {
console.error('This command runs reads only. Add a named command for a write.');
process.exit(1);
}
const endpoint = ENDPOINT.replace(/\/v1\/graphql$/, '/v2/query');
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-hasura-admin-secret': SECRET },
body: JSON.stringify({
type: 'run_sql',
args: { source: 'default', sql: statement, read_only: true },
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload) {
console.error(payload?.error ?? `HTTP ${response.status}`);
process.exit(1);
}
const rows = payload.result ?? [];
for (const row of rows) console.log(row.join('\t'));
console.log(`\n${Math.max(0, rows.length - 1)} rows`);
}
/**
* Why the app shows fewer products than the console does.
*
* node scripts/db.mjs appgap <tenantid> <locationid>
*
* `getproductsbysubcategory` is what the customer app browses with, and three
* separate conditions decide whether a product survives it. None of them is an
* error and none of them is logged — a product that fails any one simply is not
* in the response, which is why the console can be full and the app empty.
*
* A. `WHERE a.categoryid = ?` — the caller passes 2, and the filter is not
* optional (`productRepository.go:865`). A product in any other category is
* invisible to this endpoint no matter what else is true of it.
*
* B. `WHERE pl.locationid = ?` on a LEFT JOIN to `productlocations`. A product
* with no row for THIS outlet joins to NULL, and the WHERE then drops it.
* Being in the catalogue is not the same as being on a shelf: something has
* to write `productlocations`, and nothing does that automatically.
*
* C. The grouping in `GetProductsBySubcategory` walks the real subcategories
* of category 2 and collects products matching each, then sweeps up
* everything with `subcategoryid = 0` as "Uncategorized". A product whose
* subcategoryid is non-zero but is NOT a subcategory of category 2 matches
* neither loop and vanishes — it is in the query results and absent from
* the response. This one is worth looking for first, because it looks like
* nothing at all.
*/
async function appgap(tenantid, locationid) {
const tid = Number(tenantid);
const lid = Number(locationid);
if (!tid || !lid) {
console.error('Usage: node scripts/db.mjs appgap <tenantid> <locationid>');
process.exit(1);
}
// GraphQL, not `run_sql`.
//
// `run_sql` lives on Hasura's `/v2/query` admin API, which answered 404 here —
// it is disabled on managed instances and behind a different path on others.
// Three ordinary queries and the bucketing done in JS needs none of that, and
// works on any Hasura the admin secret can reach.
const data = await gql(
`query ($tid: Int!, $lid: Int!) {
products(where: { tenantid: { _eq: $tid } }) {
productid productname categoryid subcategoryid
}
productlocations(where: { tenantid: { _eq: $tid }, locationid: { _eq: $lid } }) {
productid
}
productsubcategories(where: { categoryid: { _eq: 2 } }) {
subcategoryid subcategoryname
}
}`,
{ tid, lid },
);
const products = data.products ?? [];
const listed = new Set((data.productlocations ?? []).map((row) => row.productid));
const realSubs = new Map(
(data.productsubcategories ?? []).map((row) => [row.subcategoryid, row.subcategoryname]),
);
if (products.length === 0) {
console.log(`Tenant ${tid} has no products at all.`);
return;
}
const buckets = new Map();
const examples = new Map();
for (const product of products) {
let reason;
if (product.categoryid !== 2) {
reason = 'A. categoryid is not 2 — the app only asks for category 2';
} else if (!listed.has(product.productid)) {
reason = 'B. not listed at this outlet — no productlocations row';
} else if (product.subcategoryid !== 0 && !realSubs.has(product.subcategoryid)) {
reason = 'C. subcategoryid is not a real subcategory of 2 — silently dropped';
} else {
reason = 'OK. should appear in the app';
}
buckets.set(reason, (buckets.get(reason) ?? 0) + 1);
if (!examples.has(reason)) examples.set(reason, product);
}
console.log(`${products.length} products on tenant ${tid}\n`);
const ordered = [...buckets.entries()].sort((a, b) => b[1] - a[1]);
for (const [reason, count] of ordered) {
const sample = examples.get(reason);
console.log(` ${String(count).padStart(5)} ${reason}`);
console.log(
` e.g. ${sample.productname} (id ${sample.productid}, category ${sample.categoryid}, subcategory ${sample.subcategoryid})`,
);
}
console.log(`\nReal subcategories of category 2: ${[...realSubs.values()].join(', ') || '(none)'}`);
}
/* ── Dispatch ─────────────────────────────────────────────────────────────── */
const [command, ...rest] = process.argv.slice(2);
const COMMANDS = {
tables: () => tables(),
user: () => user(rest[0]),
setpw: () => setpw(rest[0], rest[1]),
sql: () => sql(rest.join(' ')),
appgap: () => appgap(rest[0], rest[1]),
};
if (!command || !COMMANDS[command]) {
console.log(
[
'node scripts/db.mjs <command>',
'',
' tables every table Hasura has tracked',
' user <email> show an account (never prints the password)',
' setpw <email> <password> set a password on an account that has none',
' sql "<select ...>" read-only SQL',
' appgap <tenant> <outlet> why the app shows fewer products than the console',
].join('\n'),
);
process.exit(command ? 1 : 0);
}
// Locate Hasura before anything talks to it.
await resolveEndpoint();
await COMMANDS[command]();

167
scripts/mapPreview.mjs Normal file
View File

@@ -0,0 +1,167 @@
/**
* Build a standalone page that renders the REAL dispatch map with mock stops.
*
* ── Why this exists ─────────────────────────────────────────────────────────
*
* A leaflet map cannot be checked by anything else in this repo. The pure tests
* never mount it, `renderToString` never runs the effect that builds it, and
* the jsdom tests can only COUNT what it produced — none of them can tell you
* whether the thing looks right. The map shipped twice on that basis and came
* back wrong twice: once invisible behind a crash, once with its routes buried
* under 966 pins.
*
* So this bundles the actual `GroupMap` — not a copy of it, not a sketch —
* against invented stops, and writes one HTML file to open. What you see is
* what the console draws.
*
* ── Why the mock data lives here and not in `src` ───────────────────────────
*
* `npm run verify:live` asserts there is no `src/demo`, and it is right to:
* a fixture layer inside the app is how a screen ends up quietly rendering
* invented numbers in production. This is a build tool. It imports from `src`
* and nothing in `src` imports it, so the app has no path to this data.
*
* node scripts/mapPreview.mjs → writes scripts/.preview/map.html
*/
import { build } from 'esbuild';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const out = join(here, '.preview');
/* ── The mock day ─────────────────────────────────────────────────────────
Shaped like the real thing rather than like a neat demo: one shop, two
riders working outward in a loop, three orders stacked on one address, and
one rider whose last reported position is nowhere near their last drop. Each
of those is something the live data does and each has broken this map once. */
const SHOP = { lat: 11.0168, lng: 76.9558 };
const ROUND_A = [
[11.0245, 76.9601],
[11.0298, 76.9662],
[11.0331, 76.9754],
[11.0288, 76.9823],
[11.0201, 76.9788],
// Three orders at one address — the repeat customer that stacked 379 pins.
[11.0154, 76.9702],
[11.0154, 76.9702],
[11.0154, 76.9702],
];
const ROUND_B = [
[11.0102, 76.9481],
[11.0044, 76.9412],
[10.9981, 76.9377],
[10.9932, 76.9455],
[11.0011, 76.9521],
];
function stopsFor(rider, userid, points, from) {
return points.map((point, index) => ({
kind: 'delivery',
row: {
deliveryid: userid * 100 + index,
orderid: `916-${userid}${String(index + 1).padStart(2, '0')}`,
userid,
ridername: rider,
// A couple left open, so the status colours are visible on the pins.
orderstatus: index === points.length - 1 ? 'active' : 'delivered',
assigntime: '2026-08-25 09:00:00',
deliverytime: `2026-08-25 ${String(from + Math.floor(index / 2)).padStart(2, '0')}:${String((index * 17) % 60).padStart(2, '0')}:00`,
pickuplat: String(SHOP.lat),
pickuplon: String(SHOP.lng),
droplat: String(point[0]),
droplon: String(point[1]),
// Only the last stop carries a rider fix, which is the shape the live
// rows have — a position arrives when a job moves, not per stop.
...(index === points.length - 1
? { riderslat: String(point[0] + 0.004), riderslon: String(point[1] - 0.003) }
: {}),
deliveryamt: 30 + index * 5,
deliverycustomer: `${rider}'s customer ${index + 1}`,
deliveryaddress: `Stop ${index + 1}`,
},
}));
}
const STOPS = [
...stopsFor('Varun', 897, ROUND_A, 10),
...stopsFor('Murali', 1111, ROUND_B, 11),
];
const entry = join(out, 'entry.jsx');
mkdirSync(out, { recursive: true });
writeFileSync(
entry,
`import { createRoot } from 'react-dom/client';
import { GroupMap } from '../../src/features/store-admin/GroupMap';
const STOPS = ${JSON.stringify(STOPS)};
createRoot(document.getElementById('root')).render(
<div style={{ padding: 16, maxWidth: 1100, margin: '0 auto' }}>
<h1 style={{ font: '600 18px system-ui', margin: '0 0 4px' }}>Dispatch map — mock day</h1>
<p style={{ font: '13px system-ui', color: '#5b6472', margin: '0 0 16px' }}>
Two riders, one shop, ${STOPS.length} stops. Three of Varun's orders are at one address.
This is the real GroupMap component with invented stops.
</p>
<GroupMap stops={STOPS} groupName="the mock day" />
</div>,
);
`,
);
await build({
entryPoints: [entry],
bundle: true,
outfile: join(out, 'map.js'),
jsx: 'automatic',
format: 'iife',
platform: 'browser',
// Leaflet's stylesheet references sprite PNGs for controls we do not use;
// inlined as data URIs so the page is a single self-contained file.
loader: { '.css': 'css', '.png': 'dataurl', '.svg': 'dataurl' },
// The real thing, minus the parts a static page has no business having.
// `import.meta.env` is Vite's and does not exist here; the modules that read
// it already optional-chain, so an empty object is enough.
define: { 'import.meta.env': 'undefined', 'process.env.NODE_ENV': '"production"' },
logLevel: 'warning',
});
writeFileSync(
join(out, 'map.html'),
`<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dispatch map preview</title>
<link rel="stylesheet" href="./map.css">
<style>
/* The console's own tokens, so the map is styled as it is in the app. */
:root {
--color-brand: #662582; --color-brand-strong: #531b6e; --color-brand-tint: #f4eef8;
--color-ink-1: #1e2530; --color-ink-2: #414a58; --color-ink-3: #5b6472; --color-ink-4: #97a1b0;
--color-surface: #fff; --color-surface-subtle: #fafbfc; --color-surface-sunken: #f2f4f7;
--color-border: #e3e7ec;
}
body { margin: 0; background: #fff; font-family: system-ui, sans-serif; }
.pva-note { display:flex; gap:7px; align-items:flex-start; padding:8px 10px; border-radius:7px;
background: var(--color-surface-subtle); font-size:11.5px; line-height:1.5; color: var(--color-ink-3); }
.map-key { display:inline-flex; gap:5px; align-items:center; font-size:11px; color:var(--color-ink-4); }
.map-key i { width:9px; height:9px; margin-left:6px; border-radius:50%; background:var(--color-ink-4); }
.map-key i:first-child { margin-left:0 }
.map-key i[data-key='shop'] { border-radius:2px; background:var(--color-brand) }
.map-key i[data-key='drop'] { background:#10b981 }
.map-key i[data-key='rider'] { background:transparent; border:2px solid var(--color-ink-3) }
.rider-chip { display:inline-flex; gap:6px; align-items:center; padding:4px 10px; border:1px solid var(--color-border);
border-radius:999px; background:#fff; font:inherit; font-size:12px; color:var(--color-ink-2); cursor:pointer }
.rider-chip[data-active='true'] { border-color:var(--color-brand); background:var(--color-brand-tint); color:var(--color-ink-1) }
.rider-chip i { width:8px; height:8px; border-radius:50% }
</style>
<div id="root"></div>
<script src="./map.js"></script>
`,
);
console.log('preview written to', join(out, 'map.html'));
console.log('open it in a browser to see the real map with mock stops');

211
scripts/refileCategories.ts Normal file
View File

@@ -0,0 +1,211 @@
/**
* Puts a tenant's existing products into the aisles the customer app displays.
*
* Everything imported before this carries `subcategoryid: 0`, which the app
* renders as one heading called "Uncategorized" holding the entire shop —
* measured on live tenant 1135/1166 — while the catalogue has known all along
* that an Aachi masala is Spices & Masalas. This reads that answer back, folds
* it into one of the app's ten aisles (`appAisle.ts`) and writes it.
*
* `categoryid` is deliberately NOT changed. `getproductsbysubcategory` filters
* on it with the 2 the app sends, so a per-product categoryid does not label a
* product, it removes it from the app entirely.
*
* npx tsx scripts/refileCategories.ts 1147 # dry run, writes nothing
* npx tsx scripts/refileCategories.ts 1147 --apply # writes
*
* ── How a product is matched to its catalogue row ───────────────────────────
*
* On `brand` + `productsku`, never on `catalogueid`. The catalogue renumbers
* its ids on every re-scrape — 11 of 19 links were already broken when that was
* last measured — so a product's stored `catalogueid` points at whatever
* happens to sit at that number today, which may be a different product.
*
* ── What it does when there is no catalogue row ─────────────────────────────
*
* Falls back to the same deterministic ladder the import uses, so a product
* typed in by hand is filed too rather than left behind. The report says which
* source decided each one, because "the catalogue says so" and "we guessed from
* the name" are different levels of confidence and an operator reviewing 300
* rows deserves to know which is which.
*/
import { catalogueApi } from '../src/api/catalogue';
import { productsApi } from '../src/api/products';
import {
categoryForCatalogueProduct,
UNKNOWN_CATEGORY,
} from '../src/features/store-admin/productCategory';
import { aisleForCategory, aisleIdsFrom } from '../src/features/store-admin/appAisle';
import { APP_BROWSE_CATEGORY } from '../src/features/catalogue/tenantCategories';
import type { CatalogueProduct, Product } from '../src/api/types';
const tenantid = Number(process.argv[2]);
const isApply = process.argv.includes('--apply');
if (!tenantid) {
console.error('Usage: npx tsx scripts/refileCategories.ts <tenantid> [--apply]');
process.exit(1);
}
type Source = 'catalogue' | 'ladder';
interface Plan {
product: Product;
/** The subcategory the product sits in today — 0 for everything, so far. */
from: number;
/** One of the catalogue's 31, for the report. */
category: string;
/** One of the app's ten aisles, or null when the category folds to none. */
aisle: string | null;
source: Source;
}
/** Every catalogue row for one brand, keyed by SKU. One request per brand. */
async function catalogueByBrand(brand: string): Promise<Map<string, CatalogueProduct>> {
const out = new Map<string, CatalogueProduct>();
for (let page = 0; page < 20; page += 1) {
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: 500 });
for (const row of rows) {
if (row.product_sku) out.set(row.product_sku.trim().toLowerCase(), row);
}
if (rows.length < 500) break;
}
return out;
}
async function main() {
const products = await productsApi.locationProducts({ tenantid, locationid: 0, pagesize: 2000 });
console.log(`${products.length} products for tenant ${tenantid}\n`);
// One catalogue read per distinct brand, not one per product.
const brands = [...new Set(products.map((p) => (p.productbrand ?? '').trim()).filter(Boolean))];
const catalogue = new Map<string, Map<string, CatalogueProduct>>();
for (const brand of brands) {
try {
catalogue.set(brand.toLowerCase(), await catalogueByBrand(brand));
} catch {
catalogue.set(brand.toLowerCase(), new Map());
}
}
const plans: Plan[] = [];
for (const product of products) {
const brand = (product.productbrand ?? '').trim().toLowerCase();
const sku = (product.productsku ?? '').trim().toLowerCase();
const row = brand && sku ? catalogue.get(brand)?.get(sku) : undefined;
/*
The catalogue's answer only when it is one of the 31.
It carries names the platform does not have — "Food - Mixes", "Pickles &
Chutneys", "Dairy - Desserts" on about a third of the rows sampled — and
taking those verbatim would file a shop's products under aisles the app
cannot browse and no other shop shares. The same gate the import uses.
*/
const verdict = categoryForCatalogueProduct({
catalogueCategory: row?.category ?? '',
title: product.productname ?? '',
description: product.productdesc ?? '',
packSize: [product.unitvalue, product.productunit].filter(Boolean).join(' '),
});
plans.push({
product,
from: product.subcategoryid ?? 0,
category: verdict.category,
aisle: aisleForCategory(verdict.category),
source: verdict.rule === 'catalogue' ? 'catalogue' : 'ladder',
});
}
// The aisle ids, by name, from the platform's own list — see `appAisle.ts`
// for why they are matched on the name and not remembered as numbers.
const aisleIds = aisleIdsFrom(
await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY).catch(() => undefined),
);
const byAisle: Record<string, number> = {};
let unchanged = 0;
const writes: Plan[] = [];
const orphans: Plan[] = [];
for (const plan of plans) {
if (!plan.aisle) {
// Only worth reporting if the product has no aisle ALREADY. Several were
// filed by hand long before any of this, and listing those as "would stay
// under Uncategorized" says the opposite of what is true.
if (plan.from === 0) orphans.push(plan);
else unchanged += 1;
continue;
}
const to = aisleIds.get(plan.aisle.toLowerCase()) ?? 0;
if (to === plan.from) {
unchanged += 1;
continue;
}
writes.push(plan);
const key = `${plan.aisle} ← ${plan.category} (${plan.source})`;
byAisle[key] = (byAisle[key] ?? 0) + 1;
}
console.log(`${writes.length} would be re-filed, ${unchanged} already in the right aisle
`);
Object.entries(byAisle)
.sort((x, y) => y[1] - x[1])
.forEach(([name, count]) => console.log(` ${String(count).padStart(4)} ${name}`));
if (orphans.length > 0) {
console.log(
`
${orphans.length} have no aisle and would stay under the app's "Uncategorized":`,
);
orphans
.slice(0, 10)
.forEach((p) =>
console.log(
` ${(p.product.productname ?? '').slice(0, 44).padEnd(46)} ${p.category}`,
),
);
const unknown = orphans.filter((p) => p.category === UNKNOWN_CATEGORY).length;
if (unknown > 0) console.log(` (${unknown} of them could not be identified at all)`);
}
console.log('\nA sample of what changes:');
writes.slice(0, 12).forEach((p) => {
console.log(
` ${(p.product.productname ?? '').slice(0, 40).padEnd(42)} ${p.from} → ${p.aisle} (${p.source})`,
);
});
if (!isApply) {
console.log('\nDry run. Nothing was written. Re-run with --apply to write.');
return;
}
console.log('\nWriting…');
/*
One call for the whole tenant, not one request per product.
`recategorise` writes the category and subcategory columns only, and is scoped
by tenantid on the server, so it cannot reach another merchant's rows and cannot overwrite a price the
way a whole-row update would. `PUT /products/update` was the obvious candidate
and is the wrong one: it updates `productlocations.status` and never touches
the products table at all.
*/
const updates = writes
.map((plan) => ({
productid: plan.product.productid,
// Unchanged, and that is the point: it is the app's filter, not a label.
categoryid: APP_BROWSE_CATEGORY,
subcategoryid: aisleIds.get((plan.aisle ?? '').toLowerCase()) ?? 0,
}))
.filter((row) => row.subcategoryid > 0);
const skipped = writes.length - updates.length;
const result = await productsApi.recategorise(tenantid, updates);
console.log(`re-filed ${result?.moved ?? 0} of ${updates.length} sent`);
if (skipped > 0) console.log(`${skipped} skipped — no id could be resolved for their aisle.`);
}
void main();

111
src/App.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { lazy, Suspense, type ComponentType } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Spinner } from '@astryxdesign/core/Spinner';
import { RequireRole, useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { withStaleChunkRecovery } from '@/lib/staleChunk';
import { LoginPage } from '@/features/auth/LoginPage';
import { NearleAdminShell } from '@/features/nearle-admin/NearleAdminShell';
/**
* The Nearle platform console.
*
* ── Why this is its own application ─────────────────────────────────────────
*
* This is Nearle's own tool, used by Nearle's own staff at
* `platform.nearledaily.com`. The merchant console — store admins and store
* users, at `app.nearledaily.com` — is a separate repository with a separate
* deploy, and the two share no code at runtime.
*
* They began as one application with three workspaces behind a role guard, and
* were split because an internal tool and a customer-facing product want
* different things: different release cadence, different appetite for a rough
* edge, and no possibility of a change made for staff reaching a merchant.
*
* The cost is deliberate and known. Around thirty thousand lines — the API
* layer, the query cache, the component library, the drawers — exist in both
* repositories and will drift. That is accepted BECAUSE this one is internal:
* a divergence here is something the team notices in its own tool, not
* something a shop discovers. Any change worth having in both has to be made
* twice, on purpose.
*
* ── One workspace, so no workspace flag ─────────────────────────────────────
*
* The merchant repository carries `VITE_WORKSPACE` to decide which routes to
* mount, because it had to serve both. Here there is nothing to decide: every
* route below is the platform workspace, and a merchant account cannot sign in
* at all — `auth/roles.ts` resolves no other role.
*/
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. */
const NearleDispatchPage = named('NearleDispatchPage', () => import('@/features/nearle-admin/pages/DispatchPage'));
function RouteFallback() {
return (
<div style={{ display: 'grid', placeItems: 'center', padding: 64 }}>
<Spinner size="md" label="Loading" />
</div>
);
}
export function App() {
const { user } = useAuth();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<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 belongs to the merchant console — a shop opens its
own outlets. Kept as a redirect rather than deleted so a bookmark
from before the split 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
by definition: a merchant is assigned one, never creates one. */}
<Route path="partners" element={<PartnersPage />} />
<Route path="dispatch" element={<NearleDispatchPage />} />
{/* Rider tracking was its own "Fleet" page showing presence only. It is
a tab on Dispatch now; the old link still works. */}
<Route path="fleet" element={<Navigate to="/nearle/dispatch" replace />} />
<Route path="uploads" element={<NearleUploadsPage />} />
{/* Absorbed here rather than by the global `*`, so a wrong sub-path
cannot bounce out to a HOME_ROUTE pointing back into this workspace
and loop — which React Router resolves by rendering nothing at all,
a blank page with no console error. */}
<Route path="*" element={<Navigate to="/nearle/stores" replace />} />
</Route>
{/* `/admin/*` and `/store/*` are not routes here and are not guarded ones
either — they belong to the merchant console and this application has
never heard of them. A staff member who follows an old link lands on
the directory, like any other unknown path. */}
<Route
path="*"
element={<Navigate to={user ? HOME_ROUTE[user.role] : '/login'} replace />}
/>
</Routes>
);
}

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

137
src/api/catalogue.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* The global FMCG catalogue — a separate pgvector database, bridged to tenant
* data by the composite key `(brand, catalogueid)`.
*
* A catalogue row's bare `id` is only unique WITHIN its own brand table:
* `brand_dabur.id = 1` and `brand_nestle.id = 1` are different products. Every
* call that references a catalogue product sends both.
*/
import { api, WEB } from './client';
import type { CatalogueBrand, CatalogueProduct, CatalogueRef } from './types';
export interface CatalogueQuery {
/** Omit to search every brand merged — that is the "show everything" entry point. */
brand?: string;
category?: string;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const catalogueApi = {
/**
* Browse the global catalogue. Called with no `brand` this returns the full
* merged, paginated list — the list is never gated behind a brand selector.
*/
products: (query: CatalogueQuery = {}) =>
api.list<CatalogueProduct>(`${WEB}/catalogue/getproducts`, {
brand: query.brand,
category: query.category,
keyword: query.keyword,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 48,
}),
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
/**
* Every `image_id` → catalogue row id for one brand, in as few calls as the
* page size allows.
*
* Built for reconciling an ingest manifest. Resolving those one at a time is a
* request per product — a 500-row sheet would open 500 connections from a
* shop's browser — while a brand is at most a few hundred rows and comes back
* in one or two pages.
*
* `pagesize` is deliberately large but bounded, and paging stops on a short
* page rather than trusting a total the list endpoint does not return.
*/
idsByImageId: async (brand: string, pageSize = 500): Promise<Map<string, number>> => {
const out = new Map<string, number>();
for (let page = 0; page < 20; page += 1) {
const rows = await catalogueApi.products({ brand, pageno: page, pagesize: pageSize });
for (const row of rows) {
if (row.image_id && typeof row.id === 'number') out.set(row.image_id, row.id);
}
if (rows.length < pageSize) break;
}
return out;
},
/** Requires a brand — the backend reads categories from one brand's table. */
categories: (brand: string) =>
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
/**
* One catalogue row in full — the fields the import leaves behind
* (highlights, nutrients, FSSAI, every image, provider list).
*
* Returns nothing when a re-scrape has retired the source row, which is
* common: the tenant's product is a snapshot and outlives its origin.
*/
product: (brand: string, sku: string) =>
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproduct`, { brand, sku }),
/**
* One catalogue row by the id the ingest pipeline treats as canonical.
*
* An ingest run reports what it wrote as a manifest of `image_id` values, and
* `importcatalogueproduct` addresses products by `catalogueid` — the row id.
* This is the only bridge between the two, and without it a manifest could
* only be matched on the product NAME, which the owning team warns silently
* creates duplicates rather than updating.
*
* Prefer `productsByBrand` below when resolving more than a handful: this is
* one request per product.
*/
productByImageId: (brand: string, imageId: string) =>
api.get<CatalogueProduct | null>(`${WEB}/catalogue/getproductbyimageid`, {
brand,
image_id: imageId,
}),
/**
* The `(brand, catalogueid)` pairs this tenant has already imported, for
* badging "Imported" in the browser. Called without `brand` because the list
* mixes brands.
*/
importedRefs: (tenantid: number) =>
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
};
/**
* Key for the imported-refs lookup.
*
* `image_id` when there is one, and the brand-qualified id only as a fallback.
* The order matters: the catalogue is rebuilt by scrape and renumbered every
* time, so a tick placed by `catalogueid` lands on whatever product now holds
* that number — or, far more often, on nothing. Eleven of the nineteen links on
* the platform were in that state on 2026-08-31, which showed rows a shop
* really held as NOT imported and invited someone to import them again.
*
* `image_id` is the key the catalogue itself deduplicates on and survives both
* a renumber and a rename.
*/
export function catalogueKey(ref: CatalogueRef | CatalogueProduct): string {
const imageId = 'catalogueid' in ref ? ref.imageid : ref.image_id;
if (imageId) return `img:${imageId}`;
return 'catalogueid' in ref ? `${ref.brand}:${ref.catalogueid}` : `${ref.brand}:${ref.id}`;
}
/**
* Every key one imported ref can be recognised by.
*
* A ref carries both halves during the changeover — the stable key it has just
* acquired, and the id it was imported under years of scrapes ago. Emitting
* both means a browse screen keeps matching products that have not been
* relinked yet, instead of showing a shop's own stock as missing until someone
* runs the repair.
*/
export function catalogueKeysOf(ref: CatalogueRef): string[] {
const keys: string[] = [];
if (ref.imageid) keys.push(`img:${ref.imageid}`);
if (ref.catalogueid) keys.push(`${ref.brand}:${ref.catalogueid}`);
return keys;
}

View File

@@ -0,0 +1,85 @@
/**
* Which key a catalogue product is recognised by.
*
* This decides whether the browse screen shows a product as already imported.
* Getting it wrong is not cosmetic: a shop's own stock shown as missing gets
* imported a second time, and the shop ends up with duplicates.
*
* The reason it changed: `catalogueid` is renumbered by every re-scrape.
* Pepsico's live ids run 3, 6, 9 … 27, 30 — there is no 19, 25 or 26 — so on
* 2026-08-31 eleven of the nineteen links on the platform pointed at rows that
* no longer existed. `image_id` is the key the catalogue itself deduplicates on
* and survives both a renumber and a rename.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { catalogueKey, catalogueKeysOf } from './catalogue';
test('a product with a stable key is identified by it, not by its id', () => {
assert.equal(
catalogueKey({ brand: 'pepsico', id: 27, image_id: 'cheetos_chips_2d6bf74f' } as never),
'img:cheetos_chips_2d6bf74f',
);
assert.equal(
catalogueKey({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' } as never),
'img:cheetos_chips_2d6bf74f',
);
});
/*
The two sides have to agree. A ref from Fiesta and a product from the catalogue
describe the same thing under different field names — `imageid` and `image_id` —
and if they produced different keys the tick would never appear at all.
*/
test('a ref and a catalogue row agree on the key', () => {
const fromFiesta = catalogueKey({
brand: 'pepsico',
catalogueid: 27,
imageid: 'cheetos_chips_2d6bf74f',
} as never);
const fromCatalogue = catalogueKey({
brand: 'pepsico',
id: 27,
image_id: 'cheetos_chips_2d6bf74f',
} as never);
assert.equal(fromFiesta, fromCatalogue);
});
// The fallback still has to work: products imported before the column existed
// carry only the id, and they are genuinely imported.
test('without a stable key the brand-qualified id is used', () => {
assert.equal(catalogueKey({ brand: 'dabur', catalogueid: 19 } as never), 'dabur:19');
assert.equal(catalogueKey({ brand: 'dabur', id: 19 } as never), 'dabur:19');
});
// Brand-qualified, never bare. Each brand is its own table with its own
// sequence, so dabur 19 and pepsico 19 both exist and a bare id would tick the
// wrong product.
test('the fallback key keeps the brand, because ids repeat across brands', () => {
assert.notEqual(
catalogueKey({ brand: 'dabur', catalogueid: 19 } as never),
catalogueKey({ brand: 'pepsico', catalogueid: 19 } as never),
);
});
/*
During the changeover a ref carries both. Emitting only the stable key would
make every not-yet-relinked product read as missing the moment this shipped —
turning a silent problem into a visible one on every shop at once.
*/
test('a ref is recognised by both keys while the changeover runs', () => {
assert.deepEqual(
catalogueKeysOf({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' }),
['img:cheetos_chips_2d6bf74f', 'pepsico:27'],
);
});
test('a ref with only an id still yields its one key', () => {
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 19 }), ['dabur:19']);
});
// A ref with neither yields nothing rather than a key like "undefined:0" that
// would collide with every other broken ref and tick unrelated products.
test('a ref with nothing to match on yields no keys at all', () => {
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 0 }), []);
});

305
src/api/client.ts Normal file
View File

@@ -0,0 +1,305 @@
/**
* The Fiesta HTTP client.
*
* Everything the console knows about talking to the backend lives here, so the
* day the backend starts issuing a session token, this is the only file that
* 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.
*
* Set in `.env` as `VITE_API_BASE`, so the host is declared in one place rather
* than inferred here — Vite compiles it into the bundle at build time and both
* `npm run dev` and a deployed build use the same value.
*
* This module briefly decided the host itself, switching on `import.meta.env.DEV`.
* Explicit configuration is better: a rule in code that says "development means
* this, production means that" is invisible from the outside, and someone
* reading `.env` to find the backend would have found nothing.
*
* The fallback is the REAL HOST, not the same-origin `/fiesta` prefix it used
* to be. That prefix looked like a safe degradation and was not: a platform
* that writes its own `.env` into the build context (Dokploy does) erases the
* committed `VITE_API_BASE`, and the bundle then aims every call at whatever
* domain serves the console — `https://app.nearledaily.com/fiesta/live/api/...`
* instead of Fiesta. It kept working only because nginx happens to proxy that
* prefix, which is what made the misconfiguration invisible.
*
* Defaulting to the host means a missing variable can no longer silently
* re-point the backend at the console's own domain. `Dockerfile` also passes
* `VITE_API_BASE` as a build argument, so the value survives an overwritten
* `.env`.
*
* A trailing slash is stripped: every path below starts with `/`, and
* `https://host//live/api/...` is a different URL to the upstream router.
*
* Override per machine with `.env.local`, which is gitignored — set it to
* `/fiesta` to route through the dev proxy or nginx instead.
*/
/**
* Optional-chained for the same reason `ingest.ts` is: `import.meta.env` is
* Vite's, and it is undefined anywhere Vite is not — the test runner included.
* Without the `?.` this line throws on import, so every test that so much as
* names a module reaching this one fails before it runs, with a TypeError
* pointing here rather than at the test. The value already has a fallback; this
* only stops the read itself from being fatal.
*/
const configuredBase = (import.meta.env?.['VITE_API_BASE'] ?? '').trim();
export const API_BASE = (configuredBase || 'https://fiesta.nearle.app').replace(
/\/+$/,
'',
);
/** Every console route lives under this prefix. */
export const WEB = '/live/api/v1/web';
/**
* The console's POS reads — counter sales and till presence.
*
* `/web/pos`, NOT `/pos`. Those are two different doors and the difference is
* deliberate on the backend's side (`posroutes.go`): everything under `/v1/pos`
* sits behind `middleware.PosAuth`, which verifies a TERMINAL's session token.
* The console has no such token and cannot obtain one — `/pos/login` refuses an
* account that is not a till account, which is the separation working as
* intended.
*
* That guard currently waves unauthenticated requests through, so calling the
* terminal group appeared to work. The routes file says what happens next in as
* many words: "the moment `POS_AUTH_REQUIRED=true` is set, every POS screen in
* the back office goes dark." The same five reads are registered again under
* `/v1/web/pos` for exactly this caller, and that is where they belong.
*/
export const POS = '/live/api/v1/web/pos';
/**
* The mobile surface, for the two endpoints the web group does not carry.
*
* Not a preference — `tenants/getstaffs` is registered on `/v1/mob/tenants`
* only (`tenantroutes.go:35`), so the web path 404s.
*/
export const MOB = '/live/api/v1/mob';
/**
* A failed call, carrying the backend's own message.
*
* Fiesta answers HTTP 200 with `status: false` in several places, so the HTTP
* status alone is not enough to tell success from failure — both are checked.
*/
export class FiestaError extends Error {
readonly code: number;
readonly endpoint: string;
constructor(message: string, code: number, endpoint: string) {
super(message);
this.name = 'FiestaError';
this.code = code;
this.endpoint = endpoint;
}
/**
* True when the backend rejected the call for want of a scoping id.
*
* The IDOR pass added controller-level guards: an unscoped list call 400s
* rather than returning every tenant's rows. That is a bug in the caller,
* not a server fault, and it should surface as one.
*/
get isMissingScope(): boolean {
return this.code === 400 && /required/i.test(this.message);
}
}
export type QueryValue = string | number | boolean | null | undefined;
/** Drops empty params rather than sending `?tenantid=` and getting a 400 back. */
function toQueryString(params: Record<string, QueryValue> | undefined): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
params?: Record<string, QueryValue>;
body?: unknown;
signal?: AbortSignal;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', params, body, signal } = options;
// There is exactly one path out of this function and it goes to `fetch`.
//
// A fixture short-circuit used to sit here, gated on a sessionStorage flag.
// It is gone: every screen in every workspace now shows what the API
// returned or an error, and there is no longer a mode in which the console
// shows something else convincingly.
const url = `${API_BASE}${path}${toQueryString(params)}`;
const init: RequestInit = {
method,
// `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,
};
if (body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
let response: Response;
try {
response = await fetch(url, init);
} catch (cause) {
// A network failure and a 500 read very differently to a user; keep them
// distinguishable rather than collapsing both into "something went wrong".
throw new FiestaError(
cause instanceof DOMException && cause.name === 'AbortError'
? 'Request cancelled'
: 'Could not reach the server',
0,
path,
);
}
let envelope: FiestaEnvelope<T>;
try {
envelope = (await response.json()) as FiestaEnvelope<T>;
} catch {
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})`,
envelope.code ?? response.status,
path,
);
}
// 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.details ?? envelope.data) as T;
}
/**
* The whole envelope, for the handful of callers that need `message` or
* `tenantform` on success — login being the one that matters.
*/
async function requestEnvelope<T>(
path: string,
options: RequestOptions = {},
): Promise<FiestaEnvelope<T>> {
const { method = 'GET', params, body } = options;
const url = `${API_BASE}${path}${toQueryString(params)}`;
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);
}
let response: Response;
try {
response = await fetch(url, init);
} catch {
throw new FiestaError('Could not reach the server', 0, path);
}
try {
return (await response.json()) as FiestaEnvelope<T>;
} catch {
throw new FiestaError(`Malformed response (HTTP ${response.status})`, response.status, path);
}
}
export const api = {
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
request<T>(path, { method: 'GET', params, signal }),
/**
* A read that returns rows.
*
* Fiesta answers an empty result with `details: null` about as often as with
* `[]` — `Scan` into a nil slice marshals as null, and which one you get
* depends on the handler rather than on anything meaningful. A page that maps
* over the answer then dies on a white screen, and it dies for the most
* ordinary case there is: a tenant with no branches yet, a shop with no
* customers.
*
* So the coercion happens once, here, rather than as `?? []` on forty call
* sites where the one that gets forgotten is the one that breaks. A non-array
* answer is treated as empty rather than thrown, because the alternative is
* an error screen for what is usually "nothing yet".
*/
list: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
request<T[] | null>(path, { method: 'GET', params, signal }).then((rows) =>
Array.isArray(rows) ? rows : [],
),
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'POST', body, params }),
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'PUT', body, params }),
del: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'DELETE', body, params }),
envelope: requestEnvelope,
};
/** Normalises anything thrown into a message worth showing a person. */
export function errorMessage(error: unknown): string {
if (error instanceof FiestaError) return error.message;
if (error instanceof Error) return error.message;
return 'Something went wrong';
}

92
src/api/customers.ts Normal file
View File

@@ -0,0 +1,92 @@
import { api, WEB } from './client';
/**
* Customers, as one branch sees them.
*
* `gettenantcustomers` genuinely branches on `locationid`: with one it INNER
* JOINs `tenantcustomers` and returns only the people registered against that
* outlet; without one it returns the tenant's whole book. So this is one of the
* few reads where the branch scope is honoured server-side rather than by us.
*
* The pagination is the trap. The controller supplies NO defaults — a missing
* `pageno`/`pagesize` becomes `LIMIT 0 OFFSET 0`, which returns an empty list
* rather than an error, and reads on screen as "this shop has no customers".
* Both are therefore always sent from here, never left to the caller.
*/
export interface CustomerInfo {
customerid: number;
firstname?: string;
lastname?: string;
contactno?: string;
email?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
landmark?: string;
doorno?: string;
postcode?: string;
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;
}
export interface CustomerQuery {
tenantid: number;
/** Omit for the tenant's whole book. */
locationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}
export const customersApi = {
list: (query: CustomerQuery) =>
api.list<CustomerInfo>(`${WEB}/customers/gettenantcustomers`, {
tenantid: query.tenantid,
locationid: query.locationid,
keyword: query.keyword || undefined,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
}),
};
/** A display name that never renders as an empty string. */
export function customerName(customer: CustomerInfo): string {
const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
return name || customer.contactno || `Customer ${customer.customerid}`;
}
/**
* Where they are, in the shortest true form.
*
* Door numbers are dropped: "12B" tells a shopkeeper nothing, and the old
* console's fallback of "Coimbatore" for anyone without an address invented a
* locality for every record that had none.
*/
export function customerLocality(customer: CustomerInfo): string {
const parts = [customer.suburb, customer.city].filter(Boolean) as string[];
if (parts.length > 0) return parts.join(', ');
const address = (customer.address ?? '').split(',').map((part) => part.trim());
const meaningful = address.find((part) => part.length > 3 && !/^\d/.test(part));
return meaningful ?? '—';
}

490
src/api/deliveries.ts Normal file
View File

@@ -0,0 +1,490 @@
import { api, WEB } from './client';
import type { RiderInfo } from './types';
import type { DeliveryDraft } from '@/features/store-admin/assignDelivery';
/**
* Deliveries — creating them, moving them along, and finding a rider.
*
* Separate from `insights.ts`, which only READS deliveries. The split is the
* same one the backend makes: `getdeliveries` answers "what is out there", and
* these three change it.
*/
/**
* The rider has no device registered.
*
* Its own error type because the remedy differs from a transport failure: the
* rider must open the app and sign in, not retry. Collapsed into a generic
* "notification failed", an operator assumes the network is at fault and tries
* again forever.
*/
export class RiderNotReachableError extends Error {
constructor(message = 'This rider has no device registered, so they were not told.') {
super(message);
this.name = 'RiderNotReachableError';
}
}
/** What the rider is told. Kept together so the wording stays consistent. */
export const RIDER_MESSAGE = {
assigned: (count: number) =>
count === 1
? 'An order has been assigned to you. Kindly accept and process the delivery.'
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
} as const;
/**
* Which fleet to ask for. One of these, in this order of preference.
*
* `getriders` scopes by applocation, partner or tenant. It used to be called
* with the region ALONE, which asks "who is on duty in this city" — so a
* merchant's assign picker offered every on-duty rider in Coimbatore, including
* other merchants' own riders and every other partner's.
*
* Measured 2026-09-09: 118 riders across three regions, and 117 of them belong
* to a delivery partner — 75 to partner 44 alone. Exactly one rider on the
* platform is a merchant's own. So the region scope was not a harmless default;
* it was the only thing holding the picker together while the two real scopes
* went unused.
*/
export interface RiderQuery {
/** The merchant's own riders — hired by them, working their branches. */
tenantid?: number;
/** A delivery partner's riders. One partner supplies many merchants. */
partnerid?: number;
/**
* The delivery region — a CITY, and the fallback for neither of the above.
*
* Kept because a caller with no merchant in hand still has to ask something,
* not because it is the right scope for an assign picker.
*/
applocationid?: number;
}
export const deliveriesApi = {
/**
* Riders on duty right now, for one OWNER.
*
* "On duty" is the backend's word, not a filter added here: the query wants
* `app_userpools.onduty = 1` and a `riderlogs` row stamped today with
* `logstatus = 0`. So this list empties overnight and refills as riders clock
* on, and an empty answer means nobody has started their shift — not that
* the shop has no riders. The picker has to say which.
*
* ── Why this is scoped and used to not be ─────────────────────────────────
*
* It sent `applocationid` alone, which asks "who is on duty in this city" —
* so a merchant's assign picker listed every on-duty rider in Coimbatore,
* including other merchants' own riders and every partner's. Nobody hit it
* because there is one rider on the platform. `getriders` scopes by
* applocation, partner or tenant, in that order, so the caller names which
* fleet it means and the region is only a fallback for neither.
*/
riders: (query: RiderQuery) =>
api.list<RiderInfo>(`${WEB}/partners/getriders`, {
...(query.tenantid ? { tenantid: query.tenantid } : {}),
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.tenantid || query.partnerid ? {} : { applocationid: query.applocationid }),
}),
/**
* Hand orders to a rider.
*
* An array, always, because that is what the endpoint takes and because one
* call is one transaction: each row inserts a `deliveries` row, copies it to
* `deliveryqueues` for the rider's app, and moves the parent order's status.
* Verified with three orders in a single call — three deliveries, three
* queue rows, nothing duplicated.
*
* The response carries no ids, only a message, so callers refetch rather
* than patching a row in place.
*/
assign: (rows: DeliveryDraft[]) =>
api.post<unknown>(`${WEB}/deliveries/createdeliveries`, rows),
/**
* Tell a rider they have work.
*
* Deliberately NOT chained into `assign` — the deliveries are committed by
* the time this runs, so a failed push must not read as a failed assignment.
* Callers fire it afterwards and report the two outcomes separately: a rider
* who was never told has work sitting unseen, which the operator needs to
* know without being told the assignment failed.
*
* The backend holds the Firebase credentials; this only relays.
*/
notify: (token: string, body: string) => {
// Checked here rather than at the server: posting an empty token returns
// FCM's "exactly one of token, topic or condition must be specified",
// which reads as a server fault rather than as a rider who has never
// opened the app. Verified against the live endpoint.
if (!token.trim()) throw new RiderNotReachableError();
return api.post<unknown>(`${WEB}/utils/notifyuser`, {
token: token.trim(),
notification: { title: 'NearleXpress', body },
});
},
/**
* Move a delivery along its ladder, or hand it to a different rider.
*
* `deliveryid` is the only field the backend insists on — it finds the row
* with it and derives the parent order from that rather than trusting the
* caller's `orderheaderid`, which is why a partial payload is safe here.
*
* Note what the backend does NOT do: `picked` updates the delivery and stops
* there, leaving the order at its previous status. Only pending, delivered
* and cancelled are mirrored onto the order.
*/
update: (body: UpdateDelivery) =>
api.put<unknown>(`${WEB}/deliveries/updatedelivery`, body),
};
/**
* The delivery lifecycle, lowercase, as `deliveries.orderstatus` stores it.
*
* `skipped` is real and reachable — the rider got there and nobody was in —
* but it is written by the rider's app, not from here, so it is not offered.
*/
export const DELIVERY_STEPS = ['pending', 'accepted', 'arrived', 'picked', 'active', 'delivered'] as const;
export type DeliveryStep = (typeof DELIVERY_STEPS)[number];
export interface UpdateDelivery {
deliveryid: number;
orderstatus: string;
/** Sent when known so the backend does not have to look it up. */
orderheaderid?: number;
/** Set to move the job to a different rider. */
userid?: number;
assigntime?: string;
starttime?: string;
arrivaltime?: string;
pickuptime?: string;
deliverytime?: string;
canceltime?: string;
}
/* ── Riders as people, not as a fleet ────────────────────────────────────── */
/**
* One rider being hired.
*
* Flat, though it lands in three tables — `app_users` for the person,
* `ridersettings` for the vehicle and licence, `app_userpools` for their place
* in the availability pool. The caller should not have to know the table layout
* to hire somebody, and the backend writes all three in one transaction.
*
* `tenantid` is NOT here. It goes on the query string and the backend takes it
* from there, so a payload cannot put a rider on another merchant's books.
*/
export interface NewRider {
userid?: number;
firstname: string;
lastname?: string;
contactno: string;
email?: string;
password?: string;
/** The delivery region. Defaulted from the branch — see `RiderDrawer`. */
applocationid: number;
/**
* Whose rider this is — one of these, never both.
*
* `tenantid` is a merchant's own rider; `partnerid` is a delivery partner's,
* who serves several merchants and sits under no single one. The server
* refuses neither and refuses both, so the two can never be confused
* downstream in a directory or an assign picker.
*/
tenantid?: number;
partnerid?: number;
/** The branch an OWN rider works out of. Meaningless for a partner's. */
locationid?: number;
shiftid: number;
identificationno?: string;
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
registrationno?: string;
status?: string;
}
/**
* One rider in the directory.
*
* `isonduty` is the field to read for "are they working right now" — `onduty`
* is the availability flag, which is 1 for anyone who may be given work at all.
* A rider hired this morning has `onduty: 1` and `isonduty: false` until they
* open the app and start a shift.
*/
export interface RiderRosterRow {
userid: number;
firstname?: string;
lastname?: string;
fullname?: string;
contactno?: string;
email?: string;
tenantid?: number;
/** The branch an own rider works out of, and its name. */
locationid?: number;
locationname?: string;
applocationid?: number;
applocation?: string;
partnerid?: number;
partnername?: string;
shiftid?: number;
shiftname?: string;
identificationno?: string;
vehiclename?: string;
vehicleno?: string;
licenseno?: string;
registrationno?: string;
/** May be given work at all. */
onduty?: number;
lastlogdate?: string;
/** On shift right now — a log dated today. */
isonduty?: boolean;
status?: string;
}
export interface Partner {
partnerid: number;
partnername?: string;
companyname?: string;
applocationid?: number;
primarycontact?: string;
primaryemail?: string;
contactno?: string;
registrationno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
status?: string;
}
/** One region a partner covers — a row of `partnerlocations`. */
export interface PartnerLocation {
partnerlocationid: number;
partnerid: number;
applocationid: number;
applocation?: string;
}
/** A delivery region. `applocationid=0` asks for all of them. */
export interface AppLocation {
applocationid: number;
locationname?: string;
}
/** Everything the console collects to onboard a delivery partner. */
export interface NewPartner {
partnerid?: number;
partnername: string;
companyname?: string;
registrationno?: string;
primarycontact: string;
primaryemail?: string;
contactno?: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: number;
status?: string;
/** The district they work — one, never a set. */
applocationid: number;
/**
* The district by NAME, for one Nearle has not opened yet.
*
* Sending it opens the district: the server writes the `app_location` and
* `app_locationconfig` rows every rider query joins through. Ignored when
* `applocationid` is set, which is the ordinary case.
*/
district?: string;
}
export interface RiderShift {
shiftid: number;
shiftname?: string;
starttime?: string;
endtime?: string;
shifthours?: number;
}
/**
* A new working window.
*
* Times go as `HH:MM`; the server normalises `9:00` and `09:00:00` to the same
* thing, because the dropdown labels a shift by concatenating the two columns
* and the rows inserted by hand over the years use every spelling.
*/
export interface NewRiderShift {
applocationid: number;
shiftname?: string;
starttime: string;
endtime: string;
basefare?: number;
additionalcharges?: number;
fuelcharge?: number;
}
export const ridersApi = {
/**
* The directory — everyone, working today or not.
*
* NOT `getriders`, which requires a clock-in dated today. That one answers
* "who can take this delivery now" and is right for the assign picker; used
* as a staff list it hides the rider you just created, which reads as a
* failed save.
*/
roster: (tenantid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { tenantid }),
/**
* Hire one for a MERCHANT. `tenantid` travels as a param — the backend takes
* the scope from there rather than trusting the body, so a store admin cannot
* put a rider on another merchant's books by editing a payload.
*/
create: (tenantid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { tenantid }),
/**
* Hire one for a delivery PARTNER.
*
* Same endpoint, same rider — what differs is who they ride for. A partner
* has no console of its own, so their riders are added by the platform.
*/
createForPartner: (partnerid: number, rider: NewRider) =>
api.post<{ userid: number }>(`${WEB}/partners/createrider`, rider, { partnerid }),
/** A partner's riders, for the platform's directory. */
partnerRoster: (partnerid: number) =>
api.list<RiderRosterRow>(`${WEB}/partners/getriderroster`, { partnerid }),
update: (rider: NewRider & { userid: number }) =>
api.put<unknown>(`${WEB}/partners/updaterider`, rider),
/** Shifts to choose from. Scoped by region, and the param is required. */
shifts: (applocationid: number) =>
api.list<RiderShift>(`${WEB}/partners/getridershifts`, { applocationid }),
/**
* Open a shift window in a region.
*
* A rider cannot be hired without a shift, and this table could only be read
* until now — so a region that shipped with no shift rows was a region no
* rider could ever be added to, from anywhere in the product. The drawer
* showed "No shifts set up for this region" and that was the end of it.
*
* `shifthours` is deliberately not sent. The server works it out from the two
* times, because it feeds rider pay and is the one field a person gets wrong
* with nothing downstream to catch it.
*/
createShift: (shift: NewRiderShift) =>
api.post<RiderShift>(`${WEB}/partners/createridershift`, shift),
/** Delivery partners a rider can ride for. */
partners: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
};
/**
* Delivery partners — the companies that supply riders.
*
* A partner is onboarded by the platform and then ASSIGNED to merchants; a
* merchant never creates one. That split is why `assign` lives on the tenant
* API and not here, and why `partnerid` is kept out of the merchant-editable
* profile allowlist on the server.
*
* One partner routinely serves many merchants: partner 44 supplies 48 of them
* and partner 60 supplies 63, measured on 2026-09-09.
*/
export const partnersApi = {
/** Every partner in a region. `applocationid` 0 is not accepted here. */
list: (applocationid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { applocationid }),
/** One partner, by id. */
byId: (partnerid: number) =>
api.list<Partner>(`${WEB}/partners/getpartners`, { partnerid }),
create: (partner: NewPartner) =>
api.post<{ partnerid: number }>(`${WEB}/partners/createpartner`, partner),
/**
* Edit a partner. Regions are REPLACED when sent and left alone when not, so
* an edit that changes only a phone number cannot empty the list.
*/
update: (partner: NewPartner & { partnerid: number }) =>
api.put<unknown>(`${WEB}/partners/updatepartner`, partner),
/** The regions one partner covers. */
locations: (partnerid: number) =>
api.list<PartnerLocation>(`${WEB}/partners/getpartnerlocations`, { partnerid }),
/**
* Every GPS ping a partner's riders sent over a window.
*
* ── Scope, and why this is a platform endpoint ────────────────────────────
*
* It filters on `partnerid` or on the rider's `applocationid` — never on a
* tenant. A partner's riders serve every merchant that partner supplies, so
* there is no tenant this could be scoped to, and asking by region would hand
* one merchant every rider in the city. That is why the fleet view lives in
* the platform console and not in a shop's.
*
* ── What comes back, and what it is not ───────────────────────────────────
*
* One row per ping: rider, timestamp, latitude, longitude. Dense — 320,132
* rows across eight riders for August 2026 — so a month-wide window is a
* large response and callers ask for a day or two at a time.
*
* The coordinates are NOT a trail. Every row for a given rider carries the
* same pair: one rider's 2,404 pings on 14 August 2026 all read 11.052998,
* 76.929958, and the same holds on every day and region checked. The app
* stamps a location once and repeats it on each heartbeat, so distance, speed
* and "time moving" cannot be derived from this and anything of that shape
* would be invented. Rider positions that actually move are written on the
* `deliveries` rows; see `deliveryTrack`.
*
* The row also carries `login`, `logout`, `workhours`, `shorthours` and
* `breakhours`, and every one of them is empty or zero on every row measured.
* Nothing closes a shift. So the timestamps are what this endpoint is good
* for — who was online and for how long — and shifts are inferred from the
* gaps between pings; see `riderShifts`.
*/
riderLogs: (query: { partnerid?: number; applocationid?: number; fromdate: string; todate: string }) =>
api.list<RiderPingRow>(`${WEB}/partners/getriderlogs`, {
...(query.partnerid ? { partnerid: query.partnerid } : {}),
...(query.applocationid ? { applocationid: query.applocationid } : {}),
fromdate: query.fromdate,
todate: query.todate,
}),
};
/**
* One row of `getriderlogs`.
*
* The shift columns are typed because they are sent, and documented as empty
* because they are: nothing on the platform writes them. Reading `workhours`
* and believing it is the mistake this comment exists to prevent.
*/
export interface RiderPingRow {
logid?: number;
logdate: string;
userid: number;
username?: string;
partnerid?: number;
latitude?: string;
longitude?: string;
shiftid?: number;
shifthours?: number;
/** Always empty on production data. See `riderLogs`. */
login?: string;
/** Always empty on production data. See `riderLogs`. */
logout?: string;
/** Always 0 on production data. See `riderLogs`. */
workhours?: number;
shorthours?: number;
breakhours?: number;
logstatus?: number;
}

370
src/api/ingest.test.ts Normal file
View File

@@ -0,0 +1,370 @@
/**
* The review inbox, and the status that nearly slipped through as success.
*
* The fixture is the live response to an anonymous upload on 28 Aug 2026 —
* `status: "pending"`, every total zero, the file still queued.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
isAwaitingReview,
isDismissed,
isSettled,
pollDelayFor,
currentStage,
isStuckOnMissingRunner,
productsOf,
releasedRunId,
summarise,
type IngestBatch,
} from './ingest';
const held = {
batch_id: '2ee38d06b583454ea0278a7f6de2c87f',
status: 'pending',
detail: 'Waiting for review. Nothing runs until an admin starts it.',
submitted_by: 'anonymous',
files_total: 1,
files_done: 0,
files_failed: 0,
totals: {
rows_total: 0,
products_built: 0,
inserted: 0,
backfilled: 0,
skipped_existing: 0,
rejected: 0,
},
brands: [],
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const }],
} satisfies IngestBatch;
// The bug this guards: `isSettled` used to mean "not queued and not running",
// so `pending` counted as finished and the panel rendered a completed import of
// zero products for a batch that had not started.
test('a batch held for review is not treated as finished', () => {
assert.equal(isSettled(held), false, 'a held batch must not read as settled');
assert.equal(isAwaitingReview(held), true);
});
test('the summary says it is waiting, not that nothing imported', () => {
const line = summarise(held);
assert.match(line, /review/i);
assert.doesNotMatch(line, /0 added/, 'must not report an import that never ran');
});
test('a real result is still settled', () => {
for (const status of ['done', 'partial', 'failed', 'interrupted', 'cancelled'] as const) {
assert.equal(isSettled({ ...held, status }), true, `${status} should be settled`);
}
});
test('queued and running are still in flight', () => {
for (const status of ['queued', 'running'] as const) {
assert.equal(isSettled({ ...held, status }), false, `${status} should not be settled`);
}
});
// isSettled is written as a positive list precisely so a status nobody
// anticipated stalls a spinner rather than fabricating a completed import.
test('an unknown future status does not read as finished', () => {
const unknown = { ...held, status: 'quarantined' as unknown as IngestBatch['status'] };
assert.equal(isSettled(unknown), false);
});
/* ── The drop lifecycle ───────────────────────────────────────────────────── */
const released = {
...held,
batch_id: '9f088d949aa9',
status: 'pending' as const,
files: [{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' }],
} satisfies IngestBatch;
const dismissed = {
...held,
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
} satisfies IngestBatch;
// A released drop is not still waiting — the run is one hop away, and treating
// it as held would leave the screen saying "queued for review" forever.
test('a released drop is no longer awaiting review', () => {
assert.equal(releasedRunId(released), '8dcef8a2ad94');
assert.equal(isAwaitingReview(released), false);
assert.equal(isAwaitingReview(held), true, 'an unreleased drop is still waiting');
});
// Declined is terminal. Polling on is waiting for something that cannot happen.
test('a dismissed drop is recognised and reported as declined', () => {
assert.equal(isDismissed(dismissed), true);
assert.equal(isDismissed(held), false);
assert.match(summarise(dismissed), /declined/i);
assert.doesNotMatch(summarise(dismissed), /0 added/);
});
test('the manifest is collected across files', () => {
const done = {
...held,
status: 'done' as const,
files: [
{
index: 0,
filename: 'a.csv',
status: 'done' as const,
result: {
products: [
{
image_id: 'amul_amul_butter_100g',
brand: 'amul',
product_name: 'Amul Butter 100g',
product_sku: 'ACME-BUT-100',
sku_source: 'sheet',
disposition: 'inserted' as const,
},
],
},
},
],
} satisfies IngestBatch;
const products = productsOf(done);
assert.equal(products.length, 1);
// image_id is the join key; matching on name creates duplicates instead of
// updating, which is why it is asserted rather than the name.
assert.equal(products[0]?.image_id, 'amul_amul_butter_100g');
assert.equal(products[0]?.disposition, 'inserted');
});
/* ── retired: the drop is spent, the answer is on the files ───────────────── */
// A drop released into a run reads `retired`, and the run is elsewhere. Calling
// it finished would report an import that is running right now as a completed
// import of zero products.
test('a retired drop that was released is not finished', () => {
const retired = {
...held,
status: 'retired' as const,
files: [
{ index: 0, filename: 'qa.csv', status: 'released' as const, released_to: '8dcef8a2ad94' },
],
} satisfies IngestBatch;
assert.equal(isSettled(retired), false, 'the run still has to be followed');
assert.equal(releasedRunId(retired), '8dcef8a2ad94');
});
// Retired with nothing to follow is genuinely over — otherwise the panel spins
// on a drop that no longer exists.
test('a retired drop with nowhere to follow is finished', () => {
const retired = {
...held,
status: 'retired' as const,
files: [{ index: 0, filename: 'qa.csv', status: 'dismissed' as const, released_to: null }],
} satisfies IngestBatch;
assert.equal(isSettled(retired), true);
});
/* ── Cross-drop contamination ─────────────────────────────────────────────── */
// An admin can assemble one run from several drops, so a run's manifest can
// carry other senders' products. Applying our sheet's price and opening stock
// to those would stock someone else's goods into our merchant's branch.
test('only our own file contributes products', () => {
const run = {
...held,
status: 'done' as const,
files: [
{
index: 0,
filename: 'ours.csv',
status: 'done' as const,
result: {
products: [
{ image_id: 'amul_a', brand: 'amul', product_name: 'Ours', disposition: 'inserted' as const },
],
},
},
{
index: 1,
filename: 'someone-elses.csv',
status: 'done' as const,
result: {
products: [
{ image_id: 'amul_b', brand: 'amul', product_name: 'Theirs', disposition: 'inserted' as const },
],
},
},
],
} satisfies IngestBatch;
const mine = productsOf(run, ['ours.csv']);
assert.equal(mine.length, 1);
assert.equal(mine[0]?.product_name, 'Ours');
// Unfiltered still returns everything — the filter is the caller's decision,
// and every caller that prices products must make it.
assert.equal(productsOf(run).length, 2);
});
/*
The stage timeline, and the runner that silently isn't there.
Both arrived with the ingest team's 31 Aug documentation update. The timeline is
what lets the console draw the real eleven stages instead of a file-count bar;
the runner is a trap, and the more important of the two.
*/
const runningFile = {
index: 0,
filename: 'catalog.csv',
status: 'running' as const,
stage_index: 6,
stage_name: 'Image Search & Contamination Filtering',
total_stages: 11,
rows_done: 120,
rows_total: 400,
stages: [
{
index: 1,
name: 'Brand Resolution & FSSAI Licence Mapping',
rows_done: 400,
rows_total: 400,
started_at: 1756612800.1,
finished_at: 1756612801.4,
},
{
index: 6,
name: 'Image Search & Contamination Filtering',
rows_done: 120,
rows_total: 400,
started_at: 1756612809.7,
finished_at: null,
},
],
};
// `finished_at: null` is the marker, not the last array entry and not
// `stage_index`. Reading the position any other way breaks the moment a stage
// completes out of order or the array carries a trailing finished entry.
test('the running stage is the one with no finish time', () => {
const stage = currentStage(runningFile);
assert.equal(stage?.index, 6);
assert.equal(stage?.rows_done, 120);
});
// A finished file keeps its history, which is the whole reason the timeline
// exists — the scalars only ever describe the present moment, and for a
// finished file that moment is over.
test('a finished file still reports its last stage', () => {
const done = {
...runningFile,
status: 'done' as const,
stages: runningFile.stages.map((s) => ({ ...s, finished_at: s.finished_at ?? 1756612900.0 })),
};
assert.equal(currentStage(done)?.index, 6);
});
// A service build that predates the timeline still has to render. The scalars
// are the fallback, not the source of truth.
test('a response without a timeline falls back to the scalars', () => {
const { stages: _stages, ...noTimeline } = runningFile;
const stage = currentStage(noTimeline);
assert.equal(stage?.index, 6);
assert.equal(stage?.name, 'Image Search & Contamination Filtering');
});
test('a file that has not started reports no stage at all', () => {
assert.equal(currentStage({ index: 0, filename: 'a.csv', status: 'queued' }), null);
});
/*
`runner: "dagster"` never runs in production — Dagster is a development tool,
absent from the deployed image — so the batch waits for a worker that will never
claim it. Every visible signal is identical to a batch merely waiting its turn,
which is exactly why it has to be named rather than rendered as progress.
*/
test('a batch staged for the absent orchestrator is called out', () => {
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'dagster' }), true);
});
test('the in-process runner is not a stall', () => {
assert.equal(isStuckOnMissingRunner({ ...held, status: 'queued', runner: 'inprocess' }), false);
});
// A batch that reached `running` plainly found an executor, whatever it was
// staged for. Warning then would contradict the progress on screen.
test('a batch already running is not stuck, whatever it was staged for', () => {
assert.equal(isStuckOnMissingRunner({ ...held, status: 'running', runner: 'dagster' }), false);
});
/*
A drop is not a run, and the difference is easy to lose.
`released_to` lives on the DROP's files. Once an admin releases it, following
that pointer lands on the run — and the run carries no `released_to` of its own,
because nothing released it. So a caller who resolves first and asks for the run
id second gets null, and the only pointer from the id they hold to the id with
the results is never recorded.
This cost a real bug in both directions: the Uploads page never saved a run id,
and the import panel keyed the shelving write on `batch.batch_id` — which by
then was the run — updating a receipt row that does not exist, silently.
*/
test('the run id is on the drop, and gone from the run it points to', () => {
const drop = {
...held,
batch_id: 'drop-1',
status: 'retired' as const,
files: [
{ index: 0, filename: 'catalog.csv', status: 'released' as const, released_to: 'run-1' },
],
};
assert.equal(releasedRunId(drop), 'run-1');
// The same question asked of the run answers null. Read the drop first.
const run = {
...held,
batch_id: 'run-1',
status: 'done' as const,
files: [{ index: 0, filename: 'catalog.csv', status: 'done' as const }],
};
assert.equal(releasedRunId(run), null);
// And the two ids differ, which is exactly why a receipt keyed on the drop
// cannot be written using the run's.
assert.notEqual(drop.batch_id, run.batch_id);
});
/* ── How often to look, and when to stop looking ──────────────────────────── */
/*
The console used to stop polling the moment a drop went to review, on the
reasoning that waiting for an admin is not progress. It is not — but the release
IS, and stopping there meant the panel said "waiting for review" until somebody
reloaded the page. A step-by-step panel that only advances on reload is the
thing the panel exists to replace.
*/
test('a review hold is polled slowly, not abandoned', () => {
assert.equal(isAwaitingReview(held), true);
assert.equal(pollDelayFor(held), 15000, 'a hold can last hours; 2s would be 1,800 reads an hour');
});
test('a running batch is polled at a pace a person can watch', () => {
const running = { ...held, status: 'running' } satisfies IngestBatch;
assert.equal(isAwaitingReview(running), false);
assert.equal(pollDelayFor(running), 2000);
});
// A released drop is no longer waiting on anybody, so it goes back to the fast
// cadence even though its own status still reads "pending".
test('a released drop is followed at the running pace', () => {
const released = {
...held,
files: [{ index: 0, filename: 'qa.csv', status: 'queued' as const, released_to: 'run-77' }],
} satisfies IngestBatch;
assert.equal(releasedRunId(released), 'run-77');
assert.equal(isAwaitingReview(released), false);
assert.equal(pollDelayFor(released), 2000);
});

819
src/api/ingest.ts Normal file
View File

@@ -0,0 +1,819 @@
/**
* The catalogue ingest service — `mcp.nearle.ai.in`.
*
* A spreadsheet goes up, an admin reviews it, and once released the eleven-stage
* pipeline writes the products into the global catalogue. From there Fiesta
* already sees them: `/web/catalogue/getbrands` and `/web/catalogue/getproducts`
* read the SAME database the pipeline writes to, so an upload appears in the
* console with nothing in between to build or synchronise.
*
* ── A drop is not a run ──────────────────────────────────────────────────────
*
* `POST /api/uploads/catalog` creates a DROP, and nothing runs on arrival. The
* files wait in an admin review inbox; only when someone selects them and
* presses Start does a RUN begin, under a different id. The drop id stays valid
* for the whole lifecycle and its per-file status is how you follow it:
*
* queued — still in the inbox, nobody has looked
* released — accepted; `released_to` is the run, and the results are there
* dismissed — declined; nothing further is coming
*
* `resolveBatch` below makes that hop automatically, so callers poll one id and
* get whichever record actually has the answer.
*
* ── No credential ────────────────────────────────────────────────────────────
*
* The drop endpoint takes none, and that is safe precisely because of the review
* gate: an unwanted drop costs disk until somebody declines it, never products
* in the live catalogue.
*
* So `INGEST_TOKEN` should be left EMPTY. nginx omits an empty header, and a
* WRONG key is a 401 rather than a downgrade to anonymous — verified against the
* live service. A stale token in the environment would therefore break every
* upload while looking like a service fault.
*
* Only the LIST read (`GET /api/uploads/catalog`) still wants a credential;
* reading one batch by its id does not, because the id is itself the proof of
* having sent it.
*/
/**
* Optional-chained because `import.meta.env` is Vite's, and it is undefined
* anywhere Vite is not — the `node --test` runner included. Without the `?.`
* this line throws on import, so every test that so much as names this module
* fails before it runs, with a TypeError that points here rather than at the
* test. Cheap insurance for a value that already has a fallback.
*/
const INGEST_BASE = import.meta.env?.['VITE_INGEST_BASE'] ?? '/ingest';
const ROOT = '/api/uploads/catalog';
/* ── Limits, mirroring the service's own ──────────────────────────────────── */
/**
* Checked here so a drop that cannot possibly be accepted is refused in the
* browser rather than uploaded over a shop's connection to earn a 413. The
* service remains the authority; this is politeness, not validation.
*/
export const MAX_FILES = 20;
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
/** Per file. A sheet over this is marked failed; the rest of the batch runs. */
export const MAX_ROWS = 2000;
/** Everything the service parses, from the documented format list. */
export const ACCEPTED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.tsv'];
/* ── Response types, from the owning team's documented output ─────────────── */
/**
* Seven states, not four.
*
* `partial` and `interrupted` are the two that matter and the two a client is
* most likely to collapse into something else. `interrupted` means a restart
* cut the batch short; it never auto-restarts and needs an admin to resume it,
* so reporting it as `failed` would send someone re-uploading a batch that is
* waiting to be continued.
*/
export type BatchStatus =
/**
* Accepted and staged, but NOTHING RUNS until an admin releases it.
*
* A review inbox now sits in front of the pipeline — the service answers
* `"Waiting for review. Nothing runs until an admin starts it."` — and this
* status was not in the contract we were given. It matters far more than an
* extra enum member: `isSettled` originally read "not queued and not
* running", so `pending` counted as FINISHED and the panel rendered a
* completed batch reporting nothing imported. An upload that had not yet
* begun would have been shown as a successful import of zero products.
*/
| 'pending'
/**
* Every file in this DROP has been released or dismissed — the drop is spent.
*
* Not an outcome of its own: the answer is on the files. A released file
* carries `released_to`, which is where the run actually is; a dismissed one
* carries nothing because nothing will come. Treating `retired` as finished
* would report a drop that was accepted and is running right now as a
* completed import of zero products.
*/
| 'retired'
| 'queued'
| 'running'
| 'done'
| 'partial'
| 'failed'
| 'interrupted'
| 'cancelled';
/**
* A file inside a drop.
*
* `released` and `dismissed` are the review inbox's two outcomes and neither is
* a result: released means an admin accepted it and the RUN is somewhere else —
* follow `released_to` — while dismissed means they declined it and nothing will
* ever come. Reading either as a finished import reports products that were
* never written.
*/
export type BatchFileStatus =
| 'queued'
| 'running'
| 'done'
| 'failed'
| 'released'
| 'dismissed';
/**
* One product the pipeline wrote, from the run's manifest.
*
* `image_id` is the join key and the only safe one. The owning team calls it
* "the primary key every other product is deduplicated on", and warns that a
* product name differing by one character is a different product — so matching
* a manifest on NAME silently creates duplicates instead of updating.
*
* `unchanged` rows are included on purpose: re-sending a sheet writes nothing,
* and omitting them would make a completely successful upload return an empty
* list that reads as total failure.
*/
export interface IngestProduct {
image_id: string;
brand: string;
product_name: string;
product_sku?: string;
/** `sheet` when the sheet supplied it, `Internal` when the pipeline minted one. */
sku_source?: string;
disposition: 'inserted' | 'backfilled' | 'unchanged';
}
/** What the pipeline made of one file, once it has finished. */
export interface BatchFileResult {
rows_total?: number;
/** Can exceed `rows_total`: "100g, 200g, 500g" in one cell is three products. */
products_built?: number;
inserted?: number;
/** Existing rows whose blank columns this upload filled in. */
backfilled?: number;
/** Already present and already complete — nothing to do. */
skipped_existing?: number;
rejected?: number;
/** Sheet header → the field it was read as. */
recognised_columns?: Record<string, string>;
/** Headers that matched nothing. Reported, never an error. */
unrecognised_columns?: string[];
/** Non-null means rows were built but never stored. */
storage_error?: string | null;
/**
* What the run actually wrote, product by product. Returned on the
* single-batch read only — the list endpoints omit it, because twenty runs of
* thousands of rows is not a list payload.
*/
products?: IngestProduct[];
/** True when the manifest was capped at 5,000 rows for this file. */
products_truncated?: boolean;
}
/**
* One stage a file has entered, from the run's own timeline.
*
* `finished_at: null` marks the stage running RIGHT NOW — that is how the
* current position is found, not by trusting `stage_index` alone. The array
* persists after the run ends, so a finished file can still show its whole
* history; the scalars on the file only ever describe the present moment, which
* is why they are not enough on their own.
*/
export interface BatchStage {
index: number;
name: string;
rows_done?: number;
rows_total?: number;
/** Epoch SECONDS as a float, like every other timestamp here. */
started_at?: number;
finished_at?: number | null;
}
export interface BatchFile {
index: number;
filename: string;
status: BatchFileStatus;
/** Present on a file the service refused to read, and the reason it gives. */
detail?: string | null;
/**
* The RUN this file became once an admin released it.
*
* Null while it waits and after it is dismissed. The drop id stays valid for
* the whole lifecycle — an earlier build deleted the drop on release and the
* poll started 404ing, which made running, declined and lost look identical
* from outside.
*/
released_to?: string | null;
size_bytes?: number;
rows_total?: number;
/** Progress through the eleven stages, while it runs. */
stage_index?: number;
stage_name?: string;
total_stages?: number;
rows_done?: number;
/** The stages this file has entered, oldest first. */
stages?: BatchStage[];
result?: BatchFileResult | null;
}
export interface BatchTotals {
rows_total: number;
products_built: number;
inserted: number;
backfilled: number;
skipped_existing: number;
rejected: number;
}
export interface IngestBatch {
batch_id: string;
status: BatchStatus;
detail: string | null;
submitted_by?: string;
/** Epoch SECONDS, not milliseconds — multiply before handing to `Date`. */
created_at?: number;
updated_at?: number;
files_total: number;
files_done: number;
files_failed: number;
current_file?: string | null;
use_llm?: boolean;
fetch_images?: boolean;
/**
* All eleven stage names, in order.
*
* Served rather than left for us to hardcode, deliberately — draw the
* pipeline from this and the console cannot drift out of step when a stage is
* added or renamed on their side.
*/
stage_names?: string[];
/**
* Who is executing the batch.
*
* `"dagster"` is a silent failure in production and has to be surfaced rather
* than rendered as progress. Dagster is a local development orchestrator — it
* is absent from the deployed image, which never copies `orchestration/` — so
* a batch staged for it is handed to nobody and parks at `queued` forever
* saying "Waiting for the Dagster orchestrator to pick this batch up". From
* outside that is indistinguishable from a hang, and the fix is not to wait:
* an admin resumes it onto the in-process worker.
*/
runner?: 'inprocess' | 'dagster' | string;
totals: BatchTotals;
/** The brands this batch touched — the way back into the catalogue view. */
brands: string[];
files: BatchFile[];
/** Present only on the POST response; the polling reads omit it. */
message?: string;
}
export class IngestError extends Error {
readonly status: number;
readonly body: string;
constructor(message: string, status: number, body = '') {
super(message);
this.name = 'IngestError';
this.status = status;
this.body = body;
}
}
/* ── Requests ─────────────────────────────────────────────────────────────── */
export interface SubmitOptions {
files: File[];
/**
* A label for the review inbox, so the admin can see who sent what.
*
* Free text, trimmed to 60 characters by the service, and defaulting to
* "anonymous" when omitted. It is worth sending: the drop endpoint takes no
* credential, so without this every submission in the inbox is indistinguishable
* and an admin approving one cannot tell whose it is.
*/
sender?: string;
signal?: AbortSignal;
}
/**
* Refuses a drop the service is certain to reject, and says which file is at
* fault rather than reporting the batch as generically too large.
*/
function guardFiles(files: File[]): void {
if (files.length === 0) {
throw new IngestError('Choose at least one file.', 400);
}
if (files.length > MAX_FILES) {
throw new IngestError(
`That is ${files.length} files. The service takes ${MAX_FILES} per upload — send them in smaller batches.`,
413,
);
}
for (const file of files) {
if (file.size === 0) {
throw new IngestError(`"${file.name}" is empty.`, 400);
}
if (file.size > MAX_FILE_BYTES) {
throw new IngestError(
`"${file.name}" is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is 10 MB per file.`,
413,
);
}
/**
* The FILENAME picks the parser, not the bytes. A name the service does not
* recognise comes back as an unexplained parse failure, so it is named here
* instead.
*/
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!file.name.includes('.') || !ACCEPTED_EXTENSIONS.includes(extension)) {
throw new IngestError(
`"${file.name}" is not a format the service reads. Accepted: ${ACCEPTED_EXTENSIONS.join(', ')}.`,
400,
);
}
}
const total = files.reduce((sum, file) => sum + file.size, 0);
if (total > MAX_TOTAL_BYTES) {
throw new IngestError(
`That is ${(total / 1024 / 1024).toFixed(1)} MB in total. The limit is 50 MB per upload.`,
413,
);
}
}
/**
* Submits the sheets. Answers 202 with a batch to poll — it does not wait.
*
* The form field is `files` and it is REPEATED, once per file. The older
* endpoint took a single `file`, and sending that name here parses as no files
* at all.
*/
export async function submitBatch(options: SubmitOptions): Promise<IngestBatch> {
const { files, sender = 'nearle-console', signal } = options;
guardFiles(files);
const form = new FormData();
for (const file of files) form.append('files', file, file.name);
// Labels the drop in the review inbox. The endpoint takes no credential, so
// without this every submission arrives as "anonymous" and the admin deciding
// whether to run it cannot tell ours from anyone else's.
form.append('sender', sender);
// `use_llm` and `fetch_images` are no longer sent, and passing them is inert.
//
// They decide how a run behaves and commit the host to outbound work — image
// search is minutes per batch on one vCPU — so the choice belongs to the admin
// pressing Start, not to whoever dropped the file. Keeping them in the request
// would have read like control we do not have.
let response: Response;
try {
response = await fetch(`${INGEST_BASE}${ROOT}`, {
method: 'POST',
body: form,
// Content-Type is deliberately unset: the browser adds it WITH the
// multipart boundary. Setting it by hand omits the boundary and the
// server parses nothing.
headers: { Accept: 'application/json' },
...(signal ? { signal } : {}),
});
} catch (cause) {
throw new IngestError(
cause instanceof DOMException && cause.name === 'AbortError'
? 'Cancelled.'
: 'Could not reach the ingest service.',
0,
);
}
return readResponse<IngestBatch>(response);
}
/** One poll. */
export async function fetchBatch(batchId: string, signal?: AbortSignal): Promise<IngestBatch> {
let response: Response;
try {
response = await fetch(`${INGEST_BASE}${ROOT}/${encodeURIComponent(batchId)}`, {
headers: { Accept: 'application/json' },
...(signal ? { signal } : {}),
});
} catch {
throw new IngestError('Lost contact with the ingest service while waiting.', 0);
}
return readResponse<IngestBatch>(response);
}
/**
* True when the batch is sitting in the review inbox, untouched.
*
* Not a failure and not a result — it is waiting for a person. The distinction
* has to be explicit, because the two obvious ways to classify it are both
* wrong: called finished, the screen reports an import of zero products that
* never ran; called in-progress, the browser polls indefinitely for something
* only an admin can move.
*/
export function isAwaitingReview(batch: IngestBatch): boolean {
return batch.status === 'pending' && !releasedRunId(batch);
}
/**
* The run a released drop became, if an admin has accepted it.
*
* A drop is a submission, not a run. Releasing it starts a separate batch and
* records its id on the file as `released_to`; the drop id keeps working and
* keeps saying `released`, so the results are one hop away rather than at the
* id you already hold.
*
* Read off the files rather than the drop, because that is where the service
* puts it — a drop of several files can in principle be released in parts.
*/
export function releasedRunId(batch: IngestBatch): string | null {
for (const file of batch.files ?? []) {
if (file.released_to) return file.released_to;
}
return null;
}
/**
* True when an admin declined the drop. Nothing further will ever arrive, so a
* client that keeps polling is waiting for something that cannot happen.
*/
export function isDismissed(batch: IngestBatch): boolean {
const files = batch.files ?? [];
return files.length > 0 && files.every((file) => file.status === 'dismissed');
}
/**
* Follows a drop to its run, once, and returns whichever is the real answer.
*
* The caller polls a drop id. If it is released, the numbers it wants are on
* the RUN — so this hops and returns that instead. Everything else comes back
* unchanged, so a caller never has to know a drop and a run are different
* things.
*/
export async function resolveBatch(batch: IngestBatch, signal?: AbortSignal): Promise<IngestBatch> {
const runId = releasedRunId(batch);
if (!runId || runId === batch.batch_id) return batch;
try {
return await fetchBatch(runId, signal);
} catch {
// The drop is still the honest answer if the run cannot be read — better a
// stale "released" than an error for something that did succeed.
return batch;
}
}
/**
* The products a finished run wrote, optionally narrowed to our own files.
*
* `filenames` is not optional in practice and should always be passed. An admin
* can assemble ONE run from several drops — the owning team's own words: "a run
* an admin assembled from several drops lists every file in it, so you may see
* filenames batched alongside your own" — so a run's manifest can contain other
* senders' products.
*
* Reading all of them was a real hazard, not a tidiness point. The sheet's price
* and opening stock are applied to whatever the manifest is matched against, so
* a product from someone else's sheet sharing a name with one of our rows would
* have been priced and stocked from OUR file, into OUR merchant's branch.
*
* Filtering by filename is the best this contract allows and it is not airtight:
* two senders can both upload `products.csv`. Narrowing by drop would be exact,
* and the run's files carry no drop reference to narrow by — worth asking for.
*/
export function productsOf(batch: IngestBatch, filenames?: readonly string[]): IngestProduct[] {
const wanted = filenames ? new Set(filenames) : null;
return (batch.files ?? [])
.filter((file) => !wanted || wanted.has(file.filename))
.flatMap((file) => file.result?.products ?? []);
}
/**
* True once the batch has stopped moving, whatever the outcome.
*
* Listed positively rather than as "not queued and not running". The negative
* form silently absorbed every status added later — which is exactly how
* `pending` came to read as a completed import the day the review inbox
* appeared. A new status now shows up as "not settled" and stalls a spinner,
* which is visible, rather than as "done" and fabricates a result.
*/
export function isSettled(batch: IngestBatch): boolean {
// A retired drop whose files went nowhere we can follow is over. Normally
// resolveBatch has already hopped to the run, or isDismissed has caught a
// decline — this is the remainder, and leaving it unsettled would spin a
// progress bar on a drop that no longer exists.
if (batch.status === 'retired') {
return !releasedRunId(batch);
}
return (
batch.status === 'done' ||
batch.status === 'partial' ||
batch.status === 'failed' ||
batch.status === 'interrupted' ||
batch.status === 'cancelled'
);
}
/** Sleeps, unless the caller aborts first. */
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(finish, ms);
function finish() {
clearTimeout(timer);
signal?.removeEventListener('abort', finish);
resolve();
}
signal?.addEventListener('abort', finish, { once: true });
});
}
/** Resolves the moment the tab is visible again — immediately if it already is. */
function whenVisible(signal?: AbortSignal): Promise<void> {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
return Promise.resolve();
}
return new Promise((resolve) => {
const finish = () => {
if (document.visibilityState !== 'visible' && !signal?.aborted) return;
document.removeEventListener('visibilitychange', finish);
signal?.removeEventListener('abort', finish);
resolve();
};
document.addEventListener('visibilitychange', finish);
signal?.addEventListener('abort', finish, { once: true });
});
}
/** How long to wait before the next reading, given where the batch has got to. */
export function pollDelayFor(batch: IngestBatch): number {
// A run is minutes and a person is watching the stage name move.
if (!isAwaitingReview(batch)) return 2000;
// A review hold is however long their admin takes — sometimes hours. Two
// seconds against that is 1,800 requests an hour to be told "still waiting".
return 15000;
}
/**
* Polls until the batch is finished, THROUGH the review hold.
*
* ── Why it no longer stops at "awaiting review" ─────────────────────────────
*
* It used to return there, on the reasoning that waiting for an admin is not
* progress. True, but it left the console showing "waiting for review" forever
* once that admin released the drop — the steps only moved when the operator
* reloaded the page, which is the one thing a step-by-step progress panel is
* supposed to save them from. The release is exactly the transition worth
* watching: it is when the drop becomes a run and the products start arriving.
*
* So the hold is polled too, at `pollDelayFor`'s slower cadence — 15s rather
* than 2s, because a hold can last hours and a person is not watching a bar
* during one.
*
* ── And why a hidden tab costs nothing ──────────────────────────────────────
*
* Polling pauses entirely while the tab is in the background and takes a
* reading the instant it comes forward. So a sheet left open in another tab all
* afternoon makes no requests, and is up to date by the time the operator has
* looked at it — which is the same thing they used to get from reloading, minus
* the reload.
*
* `onTick` fires on each reading so the caller renders stage names and row
* counts as they move. It stops on a result and on a dismissal: a declined drop
* will never produce one.
*/
export async function pollBatch(
batchId: string,
onTick: (batch: IngestBatch) => void,
signal?: AbortSignal,
): Promise<IngestBatch> {
for (;;) {
if (signal?.aborted) throw new IngestError('Cancelled.', 0);
// Follows a released drop to the run it became, so the caller polls the
// thing that actually has progress on it rather than a record that will say
// "released" forever.
const batch = await resolveBatch(await fetchBatch(batchId, signal), signal);
onTick(batch);
if (isSettled(batch) || isDismissed(batch)) return batch;
await wait(pollDelayFor(batch), signal);
await whenVisible(signal);
}
}
/* ── Reading a response ───────────────────────────────────────────────────── */
async function readResponse<T>(response: Response): Promise<T> {
const text = await response.text();
let payload: unknown = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) throw describe(response.status, payload, text);
return payload as T;
}
/**
* `detail` is a STRING on some failures and an OBJECT on others.
*
* 400 and 413 send a sentence; 422 sends `{message, rows_total, errors[]}`.
* Rendering it straight prints "[object Object]" for exactly the response that
* carries the most useful information, so both shapes are unpacked here.
*/
function detailOf(payload: unknown): string | undefined {
if (payload === null || typeof payload !== 'object') return undefined;
const detail = (payload as { detail?: unknown }).detail;
if (typeof detail === 'string') return detail;
if (detail !== null && typeof detail === 'object') {
const nested = detail as { message?: unknown; errors?: unknown };
const message = typeof nested.message === 'string' ? nested.message : undefined;
const errors = Array.isArray(nested.errors) ? nested.errors : [];
// Row numbers are what makes a 422 actionable — they are the sheet's own
// 1-based numbering, header included, so they match what the operator sees.
const rows = errors
.slice(0, 5)
.map((entry) => {
const row = (entry as { row?: unknown }).row;
const error = (entry as { error?: unknown }).error;
return `row ${String(row)}: ${String(error)}`;
})
.join(' · ');
return [message, rows].filter(Boolean).join(' — ') || undefined;
}
return undefined;
}
/**
* The service's failures, in words that name the fix.
*
* Each of these has one cause and one remedy, and a generic "request failed"
* sends people to look at their spreadsheet for a problem that is in the
* deployment.
*/
function describe(status: number, payload: unknown, text: string): IngestError {
const detail = detailOf(payload);
const body = text.slice(0, 2000);
if (status === 401) {
// Deliberately NOT `detail ?? …`: the service answers both a missing key
// and a malformed one with a flat "Invalid API key.", which is true and
// tells nobody what to change.
return new IngestError(
'The ingest service rejected the credential. INGEST_TOKEN must be the SECRET ONLY — the 43-character value, not the `name:role:secret` triple, which fails as an invalid key rather than a malformed one. Set it on the container and restart; envsubst runs at container start, so a running container will not pick it up.',
status,
body,
);
}
if (status === 403) {
return new IngestError(
detail ??
'That key authenticated but does not hold `upload_catalog`. It needs the `uploader` or `admin` role.',
status,
body,
);
}
if (status === 413) {
return new IngestError(
detail ?? 'Too large for the service — 20 files, 10 MB each, 50 MB and 20,000 rows per upload.',
status,
body,
);
}
if (status === 429) {
return new IngestError(
detail ??
'The review inbox is full, so NOTHING was stored — this upload was not merely delayed. An admin has to clear it before you resend.',
status,
body,
);
}
if (status === 400) {
return new IngestError(
detail ??
'The service could not read that file. Every sheet needs a product-name column — product, item, variant or name.',
status,
body,
);
}
return new IngestError(detail ?? `The ingest service returned HTTP ${status}.`, status, body);
}
/* ── Reading a finished batch ─────────────────────────────────────────────── */
/** True when the batch ended without everything landing. */
export function isIncomplete(batch: IngestBatch): boolean {
return (
batch.status === 'partial' ||
batch.status === 'failed' ||
batch.status === 'interrupted' ||
batch.status === 'cancelled' ||
batch.files_failed > 0
);
}
/** One line for the top of the result panel. */
export function summarise(batch: IngestBatch): string {
const { totals } = batch;
if (isDismissed(batch)) {
// A refusal, not a failure, and nothing further is coming.
//
// The DROP-level detail is deliberately not used here. It still reads
// "Waiting for review. Nothing runs until an admin starts it." on a drop
// that has since been declined — the sentence was written when the file was
// accepted and nothing rewrites it. Rendering it would tell the operator to
// keep waiting for a decision that has already been made against them.
//
// A reason attached to the FILE is the admin's own and is worth showing.
const reason = (batch.files ?? []).map((file) => file.detail).find(Boolean);
return reason
? `An admin declined this upload: ${reason}`
: 'An admin declined this upload. Nothing was imported.';
}
if (isAwaitingReview(batch)) {
// The service's own sentence when it has one — it is clearer than anything
// invented here, and it changes if their review policy does.
return (
batch.detail ??
'Waiting for review. Nothing runs until an admin on the ingest service starts it.'
);
}
if (batch.status === 'failed') {
return batch.detail ?? 'No file could be ingested.';
}
if (batch.status === 'interrupted') {
return 'The service restarted part-way through. An admin can resume this batch — it will not restart on its own.';
}
if (batch.status === 'cancelled') {
return 'This batch was cancelled before every file ran.';
}
const parts = [`${totals?.inserted ?? 0} added`];
if ((totals?.backfilled ?? 0) > 0) parts.push(`${totals.backfilled} filled in`);
if ((totals?.skipped_existing ?? 0) > 0) parts.push(`${totals.skipped_existing} already there`);
if ((totals?.rejected ?? 0) > 0) parts.push(`${totals.rejected} rejected`);
const summary = parts.join(' · ');
return batch.files_failed > 0
? `${summary} — but ${batch.files_failed} of ${batch.files_total} files could not be read`
: summary;
}
/** Overall progress, for a bar. Stages within a file are too fine to show. */
export function progressOf(batch: IngestBatch): { done: number; total: number } {
return { done: batch.files_done + batch.files_failed, total: batch.files_total };
}
/**
* True when the batch was handed to an orchestrator that is not there.
*
* `runner: "dagster"` never runs in production: Dagster is a development tool,
* absent from the deployed image, so the batch waits for a worker that will
* never claim it and sits at `queued` indefinitely. It has to be named, because
* every visible signal — a queued status, a stage index of 0, a progress bar at
* nothing — is identical to a batch that is merely waiting its turn.
*
* Only meaningful while it is still waiting. A batch that reached `running`
* plainly found an executor whatever it was staged for.
*/
export function isStuckOnMissingRunner(batch: IngestBatch): boolean {
return batch.runner === 'dagster' && (batch.status === 'queued' || batch.status === 'pending');
}
/**
* Where a file is in the eleven stages, read from the timeline rather than the
* scalars.
*
* `stages[]` is the authority: the entry with `finished_at: null` is the stage
* running now. `stage_index`/`stage_name` describe the same moment and are used
* as a fallback for a service build that does not send the timeline, but they
* cannot show a finished file's history and the timeline can.
*
* Returns null when there is nothing to draw — a file that has not started, or
* one from a response carrying neither.
*/
export function currentStage(file: BatchFile): BatchStage | null {
const running = (file.stages ?? []).find((stage) => stage.finished_at == null);
if (running) return running;
// Finished, or a build without the timeline. The last entered stage is the
// most useful thing to show for a file that has stopped moving.
const last = (file.stages ?? []).at(-1);
if (last) return last;
if (!file.stage_index) return null;
return {
index: file.stage_index,
name: file.stage_name ?? `Stage ${file.stage_index}`,
...(file.rows_done === undefined ? {} : { rows_done: file.rows_done }),
...(file.rows_total === undefined ? {} : { rows_total: file.rows_total }),
};
}

262
src/api/insights.ts Normal file
View File

@@ -0,0 +1,262 @@
/**
* Order, delivery and POS reads — the numbers behind store performance.
*
* Note the split: online orders come from `/web/orders/*` and counter sales
* from `/pos/*`, which is the ONLY part of the API carrying auth middleware.
* Any figure that blends the two is eventually consistent by construction, so
* screens that show one must also show its freshness.
*/
import { api, POS, WEB } from './client';
import type {
DeliveryRow,
DeliverySummary,
LocationOrderSummary,
OrderItem,
OrderRow,
OrderSummary,
PosLocationHealth,
PosTerminalHealth,
PosSalesPage,
PosSalesSummary,
} from './types';
export interface DateRange {
fromdate?: string;
todate?: string;
}
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;
pagesize?: number;
}
export const insightsApi = {
/**
* The order rows themselves.
*
* `/orders/tenant/getorders` rather than the bare `/orders/getorders`: the
* controller routes on which ids are present, and passing a tenant with no
* partner, customer or app-user reaches `GetTenantOrders`. Passing a
* `locationid` as well reaches `GetTenantLocationOrders`, which is the
* branch-scoped read — so one call covers both "all branches" and "one
* branch" by presence alone.
*
* `pageno` is 1-based here. The controller floors anything <= 0 to 1, so
* sending 0 silently gives page one rather than an error.
*/
orders: (query: OrderQuery) =>
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
fromdate: query.fromdate,
todate: query.todate,
pageno: query.pageno ?? 1,
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.
*
* A separate read from the orders list, NOT a filter over it. The rows are a
* different struct with different fields — rider name, planned vs actual
* distance, rider charge vs job value, notes — and a different status ladder.
* Deriving deliveries from orders, which is what this page did first, loses
* every one of those.
*
* The controller 400s unless one of tenantid/partnerid/customerid/
* applocationid/userid/appuserid is present, so `tenantid` is required here.
*/
deliveries: (query: OrderQuery) =>
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
...(query.partnerid ? { partnerid: query.partnerid } : { tenantid: query.tenantid }),
locationid: query.locationid,
status: query.status,
keyword: query.keyword,
fromdate: query.fromdate,
todate: query.todate,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
orderSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getordersummary`, { tenantid, ...range }),
/** Per-branch order totals for one tenant. `tenantid` is required. */
locationSummary: (tenantid: number, range: DateRange = {}) =>
api.list<LocationOrderSummary>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
revenueSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
/**
* `granularity` is REQUIRED and was never sent, so this call answered 400
* every single time: "granularity query parameter is required (day, month,
* year)". Nothing renders it yet, which is the only reason it went unnoticed
* — the first screen to use it would have shown an error instead of a chart.
*
* Defaulted rather than made a required argument: a day-by-day series is what
* every caller of a dated range wants, and a parameter with one sensible
* answer should not be every caller's problem.
*/
timeSeries: (
tenantid: number,
range: DateRange = {},
granularity: 'day' | 'month' | 'year' = 'day',
) =>
api.get<Record<string, unknown>[]>(`${WEB}/orders/gettimeseries`, {
tenantid,
granularity,
...range,
}),
/**
* What is actually IN an order.
*
* The only read that carries line items. Every list endpoint returns an
* order's totals and never its contents, which is why the detail sheet could
* say an order was worth ₹840 and not what the ₹840 bought.
*
* The envelope rather than `api.list`, because the authoritative total lives
* outside `details`: `OrderDetail.Orderamount` is `json:"-"` on the server, so
* `pricedetails.orderamount` is the only place it appears. Summing the lines
* would be recomputing a figure Fiesta has already worked out, and the two
* would disagree the first time a discount rounded differently.
*/
orderItems: async (orderheaderid: number) => {
const envelope = await api.envelope<OrderItem[]>(`${WEB}/orders/getorderdetails`, {
params: { orderheaderid },
});
return {
// `details: null` for an order with no lines is as common here as `[]`;
// see the note on `api.list`.
items: envelope.details ?? [],
amount: envelope.pricedetails?.orderamount ?? 0,
tax: envelope.pricedetails?.totaltaxamount ?? 0,
};
},
deliverySummary: (tenantid: number, range: DateRange = {}) =>
api.get<DeliverySummary>(`${WEB}/deliveries/deliverysummary`, { tenantid, ...range }),
/**
* Counter sales for ONE outlet.
*
* `locationid` is required and singular — there is no tenant-wide POS call,
* so a multi-branch view fans out one request per branch.
*/
posSales: (
locationid: number,
params: DateRange & {
pageno?: number;
pagesize?: number;
/** The three server-side filters the old console's bills tab offers. */
terminalid?: string;
cashiername?: string;
paymentmode?: string;
} = {},
) =>
api.get<PosSalesPage>(`${POS}/sales`, {
locationid,
pageno: params.pageno ?? 0,
pagesize: params.pagesize ?? 50,
fromdate: params.fromdate,
todate: params.todate,
terminalid: params.terminalid,
cashiername: params.cashiername,
paymentmode: params.paymentmode,
}),
/**
* One counter bill in full, with its lines.
*
* `reference` accepts the till's order UUID, the invoice number, or this
* backend's posorderid — a support call starts from whichever the person is
* looking at, so the endpoint takes all three.
*/
posSaleDetail: (locationid: number, reference: string) =>
api.get<unknown>(`${POS}/sales/detail`, { locationid, reference }),
/**
* Counter-sales totals for ONE outlet.
*
* The richest read in the API: bill count, gross, tax, discount, roundoff and
* average bill, already broken down by payment mode, by day and by terminal.
* Reports gets its offline half from this and nothing else.
*
* Kept apart from the order summaries above on purpose. These are `posorders`
* rows; those are `orders` rows. The two are never added together — see
* `store-admin-backend-gap.md` §3.1 for why that would double-count a branch
* that both runs a till and uploads a spreadsheet.
*/
posSalesSummary: (locationid: number, range: DateRange = {}) =>
api.get<PosSalesSummary>(`${POS}/sales/summary`, { locationid, ...range }),
/**
* Till presence for one outlet — how many are online, how many bills are stranded.
*
* Returns the terminal list, not the wrapper. The endpoint answers
* `{location_id, total, online, terminals}`; every caller wants `terminals`,
* and `summariseBranch` recomputes `online` from the heartbeats anyway
* because Fiesta's figure counts a stale till as present. Unwrapping here
* keeps that one shape fact in the API layer instead of on every page.
*/
posHealth: (locationid: number): Promise<PosTerminalHealth[]> =>
api
.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid })
.then((health) => (Array.isArray(health?.terminals) ? health.terminals : [])),
};

166
src/api/nutrition.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* Health scores and nutrition, from the catalogue-intelligence service.
*
* A SEPARATE HOST from Fiesta — `mcp.nearle.ai.in`, the same service that
* scrapes the global catalogue — so it does not go through `client.ts`, which
* exists to talk to one backend. It is read-only and unauthenticated, like the
* catalogue reads beside it.
*
* ── The join key ────────────────────────────────────────────────────────────
*
* `image_id`, not `catalogueid`. That is the same stable key the catalogue
* import already uses, and for the same reason: `catalogueid` is renumbered on
* every re-scrape, so a link made through it goes stale silently. A product
* carries its `imageid` from the import, and that is what resolves here.
*
* ── Two things measured against the live service, 4 Sep 2026 ────────────────
*
* - `include_unknown=true` is REQUIRED or the list returns nothing. With it,
* 252 items; without it, zero — including products whose `data_status` is
* "verified" and whose score is a real number. The flag reads like it should
* only add unscored rows; in practice its absence removes everything.
*
* - Scoring covers ten brands (Nestle, Amul, Coca-Cola, Cadbury and six
* smaller ones). None of the brands our merchants actually stock are among
* them yet, so today this renders on no products at all. The wiring is
* correct; the data has to catch up.
*/
const NUTRITION_BASE = 'https://mcp.nearle.ai.in/api';
/** How confident the service is that it matched the right source record. */
export const LOW_CONFIDENCE = 0.7;
export interface NutritionScore {
brand?: string;
image_id?: string;
product_name?: string;
category?: string;
/** 0–100. `null` when the product is known but has not been scored. */
health_score?: number | null;
nutrition_score?: number | null;
health_band?: string | null;
scoring_version?: string | null;
/** Sentences, already written for a person. Rendered as given. */
positive_insights?: string[];
nutritional_cautions?: string[];
ai_summary?: string | null;
diet_tags?: string[];
allergens?: string[];
/** "verified" when the source record was confirmed. */
data_status?: string | null;
data_source?: string | null;
source_url?: string | null;
/**
* 0–1. The 5 Star record scores 0.577 — a moderate match, not a certainty.
*
* Surfaced rather than hidden. A nutrition panel presented as fact when the
* underlying match is a guess is worse than no panel, and that goes double
* for the allergen list.
*/
match_confidence?: number | null;
serving_size_g?: number | null;
serving_size_label?: string | null;
calories_kcal?: number | null;
protein_g?: number | null;
carbohydrates_g?: number | null;
total_sugar_g?: number | null;
added_sugar_g?: number | null;
dietary_fiber_g?: number | null;
total_fat_g?: number | null;
saturated_fat_g?: number | null;
sodium_mg?: number | null;
}
async function read<T>(path: string): Promise<T | null> {
let response: Response;
try {
response = await fetch(`${NUTRITION_BASE}${path}`, {
headers: { Accept: 'application/json' },
});
} catch {
// A nutrition panel is an enhancement on a product page. If the service is
// unreachable the page still has to render, so this reports "nothing"
// rather than throwing into the drawer.
return null;
}
if (!response.ok) return null;
try {
return (await response.json()) as T;
} catch {
return null;
}
}
/**
* Their brand spelling, resolved from ours.
*
* The two catalogues agree on every brand and disagree on how to write it:
*
* ours theirs
* cadbury → Cadbury
* coca_cola → Coca-Cola underscore becomes a HYPHEN
* brooke_bond → Brooke Bond underscore becomes a SPACE
* 24_mantra → 24 Mantra
*
* Which separator an underscore becomes cannot be derived — it is a hyphen for
* Coca-Cola and Colgate-Palmolive and a space for everything else. So the list
* is fetched and matched on a normalised form rather than guessed at.
*
* This is not cosmetic. `GET /nutrition/cadbury/...` returns
* `health_score: null` — a well-formed answer meaning "no score", not an error
* — so getting the case wrong looks exactly like a product nobody has scored,
* on every product, forever.
*
* Cached for the process: a brand list changes when the scraper learns a new
* brand, which is not during a session.
*/
let brandsPromise: Promise<string[]> | null = null;
function normalise(brand: string): string {
return brand.toLowerCase().replace(/[^a-z0-9]/g, '');
}
async function resolveBrand(raw: string): Promise<string> {
const wanted = normalise(raw);
if (!wanted) return raw;
brandsPromise ??= read<{ brands?: string[] }>('/brands').then((r) => r?.brands ?? []);
const brands = await brandsPromise;
// Their exact spelling if we know it; ours unchanged if we do not, so a brand
// they have not listed still gets a real attempt rather than being dropped.
return brands.find((candidate) => normalise(candidate) === wanted) ?? raw;
}
export const nutritionApi = {
/**
* One product's score and nutrition.
*
* Returns null when the product is unknown to the service, and a record with
* `health_score: null` when it is known but unscored — two different answers
* that must not be collapsed, because the second means "coming soon" and the
* first means "this product was never in the catalogue".
*/
forProduct: async (brand: string, imageId: string) => {
// Resolved first: our catalogue spells brands in snake_case and theirs does
// not, and the mismatch reads as "unscored" rather than as an error.
const resolved = await resolveBrand(brand);
return read<NutritionScore>(
`/nutrition/${encodeURIComponent(resolved)}/${encodeURIComponent(imageId)}`,
);
},
};
/** Exposed for the check script, which asserts the two vocabularies still line up. */
export const __resolveBrand = resolveBrand;
/** True when the service knows the product but has not scored it yet. */
export function isUnscored(score: NutritionScore | null): boolean {
return score !== null && (score.health_score === null || score.health_score === undefined);
}

244
src/api/optimiser.ts Normal file
View File

@@ -0,0 +1,244 @@
import type { SolverRequest, Tuning } from '@/features/store-admin/autoAssign';
import type { OrderRow } from './types';
/**
* The route optimiser.
*
* A SEPARATE SERVICE from Fiesta — `routes.workolik.com`, "Route Optimization
* API v2.0.0" — so it does not go through `client.ts`, which exists to talk to
* one backend. Road routing is real (a Valhalla backend, not straight lines)
* and the assignment model is trained: 3,627 records, tuned to 20 orders per
* rider and an ideal load of 4.
*
* ── What it is and is not ───────────────────────────────────────────────────
*
* `optimization/createdeliveries` is NOT a create, despite the name it shares
* with Fiesta's. It is a pure function: send an array of orders, get the same
* array back reordered nearest-neighbour with `step`, `previouskms`,
* `cumulativekms`, `actualkms` and `eta` added. Its own docs say forwarding is
* paused, and the verified behaviour matches — it writes nothing anywhere.
*
* So the sequence is ours to commit: we take its answer and post it to Fiesta's
* `deliveries/createdeliveries` ourselves. That is also what the xpress console
* does, which is the only reason its two identically-named endpoints do not
* collide.
*
* ── `riderassign` assigns against OUR fleet, not a foreign one ──────────────
*
* This file used to say the opposite — that `riderassign` was useless because
* it returned orders assigned to `rider_id 883, "Rajan A"`, "not one of ours".
* That was wrong, and it was wrong for the ordinary reason: an unfamiliar id
* was taken for a stranger without checking the roster.
*
* Checked on 2026-09-10. `getriderroster?partnerid=44` lists 883 "Rajan A", and
* so do the rider ids on tenant 916's own delivery rows — 883, 897, 950, 1111,
* 1114, every one of them partner 44's, which is the Coimbatore fleet. The
* solver reads the same database Fiesta does: `getallriders` on jupiter and
* `getriders` on Fiesta return identical rosters and identical on-duty state.
*
* So auto-assignment works and `assign` below wires it up.
*
* ── What it cannot do yet, and why that is not our bug ──────────────────────
*
* The solver picks the riders itself, gated on `onduty = 1`, and that flag is 0
* for all 118 riders on the platform — every region, checked the same day. So
* `active_riders_pool` is 0 and every order comes back unassigned with "No
* riders found (check partner online status)". Supplying riders in the body
* does not help: `riders` and `active_riders` were both tried against a rider
* the on-duty endpoint DOES report, and the pool stayed 0.
*
* Whatever is meant to set `onduty` is not setting it. That is worth asking the
* app team about; nothing here can work around it.
*
* ── `routemate` is gone ─────────────────────────────────────────────────────
*
* The old console's second mode posted to `routemate.workolik.com/api/v1/
* optimization/riderassign?strategy=multi_trip`, which accepted a rider list
* inline. It answers 404 now, with and without the query string, so that route
* around the `onduty` gate is closed too.
*/
const OPTIMISER_BASE = 'https://routes.workolik.com/api/v1';
/** A solve can legitimately take a while. Past this, something is wrong. */
const SOLVE_TIMEOUT_MS = 90_000;
/** An order as the optimiser hands it back — ours, plus the routing it added. */
export interface SequencedStop extends OrderRow {
/** 1..N. The order to visit in. */
step?: number;
/** Kilometres from the previous stop. */
previouskms?: number;
/** Running total for the round. */
cumulativekms?: number;
/**
* Direct pickup-to-delivery distance, as a string.
*
* The service returns these as strings ("1.23"), which is also how Fiesta's
* `deliveries.kms` / `actualkms` columns are typed — so they carry across
* unconverted. Those columns are exactly the ones found holding the literal
* text "null" in production, which broke the rider summary; a sequence run is
* what should be filling them with real numbers.
*/
actualkms?: string;
kms?: string;
/** Minutes for this leg, and cumulative. Strings, as sent. */
eta?: string;
cumulative_eta?: string;
ordertype?: string;
}
/** One rider's leg of a plan, in the shape reconcile expects back. */
export interface PlannedRider {
rider_id: string | number;
rider_name?: string;
orders: SequencedStop[];
}
export class OptimiserError extends Error {
constructor(message: string) {
super(message);
this.name = 'OptimiserError';
}
}
async function post<T>(path: string, body: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${OPTIMISER_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
} catch {
// A separate host means a separate failure mode: the optimiser can be down
// while Fiesta is fine. Said plainly so nobody debugs the wrong service.
throw new OptimiserError('Could not reach the route optimiser');
}
let payload: { code?: number; details?: T; message?: string; error?: { message?: string } };
try {
payload = await response.json();
} catch {
throw new OptimiserError(`The optimiser sent a malformed reply (HTTP ${response.status})`);
}
if (!response.ok) {
throw new OptimiserError(payload?.error?.message || payload?.message || `Optimiser refused the request (HTTP ${response.status})`);
}
// It answers `{code, details}` on the sequencing route and a bare object
// elsewhere, so both shapes are unwrapped here rather than at each call site.
return (payload.details ?? (payload as unknown)) as T;
}
/**
* A run that is allowed to take its time, and to be cancelled.
*
* Separate from `post` for two reasons: the caller needs the whole envelope
* rather than `details`, and a solve is slow enough that abandoning it has to
* be possible. The caller's cancel and the timeout both have to be able to stop
* it, so they are combined rather than one winning.
*/
async function postRaw(path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
const timer = new AbortController();
const stop = setTimeout(() => timer.abort(), SOLVE_TIMEOUT_MS);
const onAbort = () => timer.abort();
signal?.addEventListener('abort', onAbort);
try {
const response = await fetch(`${OPTIMISER_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: timer.signal,
});
if (!response.ok) {
// 422 is the solver rejecting the payload and saying which field. Worth
// showing verbatim — "422" on its own is not actionable.
const text = await response.text().catch(() => '');
throw new OptimiserError(
text.trim().slice(0, 400) || `Optimiser refused the request (HTTP ${response.status})`,
);
}
return await response.json();
} catch (error) {
if (error instanceof OptimiserError) throw error;
if ((error as Error)?.name === 'AbortError') {
throw new OptimiserError(
signal?.aborted
? 'Cancelled.'
: 'The optimiser did not answer in time. Nothing was assigned — the orders are untouched.',
);
}
throw new OptimiserError('Could not reach the route optimiser');
} finally {
clearTimeout(stop);
signal?.removeEventListener('abort', onAbort);
}
}
export const optimiserApi = {
/**
* Put a set of orders in a sensible order.
*
* Send them in any order; they come back sorted with a step number and the
* distance and time between each. Verified against the live service with our
* own field names — it reads `pickuplat`/`pickuplong` and
* `deliverylat`/`deliverylong`, which order rows already carry.
*/
sequence: (orders: OrderRow[]) =>
post<SequencedStop[]>('/optimization/createdeliveries', orders),
/**
* Repair step numbers after somebody moved a stop by hand.
*
* Moving one order between riders breaks two rounds at once: the rider who
* lost it has a hole in its sequence (1,2,3,5,6) and the one who gained it
* has a step that collides or is missing. This fixes both.
*
* It MUST run before the plan is committed. The team who built the page this
* came from call skipping it "the single biggest production bug to avoid in
* this area" — it corrupts route sequences in the database, and nothing at
* the point of the write can tell.
*
* Send only the riders that were edited; the response carries those riders
* back and the rest of the plan is left alone.
*/
reconcile: (riders: PlannedRider[]) =>
post<{ riders: PlannedRider[] }>('/optimization/reconcile-steps', { riders }),
/**
* Propose a rider for each waiting order.
*
* A PLAN, not a commitment. Nothing is written anywhere until the operator
* accepts it and the console makes its own `createdeliveries` call to Fiesta
* through `buildDelivery` — the same path the manual assign bar uses, so
* there is exactly one way a delivery is ever written. Safe to run twice and
* safe to walk away from.
*
* ── Raw, not unwrapped ────────────────────────────────────────────────────
*
* `postRaw`, because the answer here IS the envelope: `zones` carries the
* assignment, `meta` carries the accounting and the per-order reasons, and
* `details` is only the flat fallback shape. `post` would hand back `details`
* alone and throw the plan away — and `details` is `[]` on every run that
* assigns nothing, which is every run today.
*
* ── Slow on purpose ───────────────────────────────────────────────────────
*
* Seven seconds for five orders, measured, and it is a solver so it grows
* with the problem. `signal` is taken so a caller can offer to cancel; the
* timeout is deliberately generous, since killing a run early abandons work
* the operator is waiting on and teaches them the button is broken.
*
* `tuning` steers it — balanced, aggressive_speed, fuel_saver, zone_strict —
* and the literal string `null` is a value it accepts, meaning "your default".
*/
assign: (request: SolverRequest, tuning: Tuning | null, signal?: AbortSignal) =>
postRaw(
`/optimization/riderassign?hypertuning_params=${tuning ?? 'null'}`,
request,
signal,
),
};

252
src/api/people.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* People — back-office staff and till accounts.
*
* Two account systems that happen to share one table. A till account is NOT a
* Nearle Daily user: the backend excludes roles 7 and 8 from every application
* lookup inside the query itself, deliberately, so a cashier is "not found"
* rather than "refused". They are read and written through different endpoints
* with different conventions, and this file keeps them apart.
*
* Three things this module will not do, each for a reason recorded in
* `store-admin-user-menu-plan.md`:
*
* - **No delete.** `DELETE /users/delete` and `DELETE /deleteposuser` are hard
* deletes with no cascade. Deactivating via `status` is the safe equivalent
* and is what both list screens offer.
* - **No password management.** `PUT /users/update` doubles as the
* password-reset call and passwords are stored in clear. Creating an account
* is in scope; issuing its password is not, until hashing exists.
* - **Never send `roleid: -1`.** The old console clears a role that way;
* `UpdateStaff` does not special-case it, so `-1` lands in the column and the
* account ends up holding a role that matches nothing.
*/
import { api, WEB } from './client';
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
/* ── Back-office staff ───────────────────────────────────────────────────── */
export interface CreateStaffRequest {
tenantid: number;
locationid: number;
firstname: string;
lastname?: string;
email: string;
contactno: string;
roleid: number;
status?: string;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
lastname?: string;
email?: string;
contactno?: string;
roleid?: number;
locationid?: number;
status?: string;
}
export const staffApi = {
/**
* The tenant's back-office directory.
*
* `getstaffs`, NOT `getallusers`. This one resolves `rolename` server-side,
* and the backend says why that matters: "`app_roles` holds six rows for four
* back-office roles and most accounts carry an id absent from it, so any
* mapping written client-side is wrong."
*
* On WEB now. It used to be on MOB because `getstaffs` was registered under
* `/v1/mob/tenants` alone and had no `/web` twin — back-office staff were
* reachable only through the customer app's door, which is a large part of
* why this console never had a people screen. The twin now exists; the MOB
* registration is left in place in case something else calls it.
*
* Returns people with NO branch as well as people with one. That is the whole
* point: `locationid` 0 means hired and not yet placed, and the list is
* ordered to put them first, because they are the rows needing an action.
*/
list: (tenantid: number) =>
api.list<StaffInfo>(`${WEB}/tenants/getstaffs`, { tenantid }),
/**
* Put somebody at a branch, or take them off one.
*
* `unassign` is a separate flag rather than `locationid: 0`, deliberately. A
* body that lost the field, a form that posted a blank and a client that
* dropped it all arrive as 0 — so a zero alone must never mean "take them off
* their shop". The backend refuses it too; this mirrors the rule so the
* refusal is not a round trip.
*/
assign: (body: { tenantid: number; userid: number; locationid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, body),
unassign: (body: { tenantid: number; userid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
/**
* Update a person.
*
* GORM's `Updates` with a struct skips zero values, so an omitted field is
* left alone rather than blanked — which is why every field here is optional
* and why clearing something is not possible through this call.
*/
update: (body: UpdateStaffRequest) => api.put<StaffInfo>(`${WEB}/users/update`, body),
};
/* ── Till accounts ───────────────────────────────────────────────────────── */
export interface CreatePosUserRequest {
tenantid: number;
locationid: number;
full_name: string;
/**
* The role NAME, lowercase — "supervisor" or "cashier".
*
* Not the id. `PosRoleFromName` reads the name off the request and returns 0
* for anything it does not recognise, which every caller treats as a refusal
* rather than as a default. Sending 7 or 8 here does nothing.
*/
role: string;
/** Ten digits. The till matches on it exactly — see the note in `normaliseMobile`. */
contactno: string;
pin?: string;
/** A `staffshifts.staff_shift_id`. Zero leaves it unset. */
shift_id?: number;
status?: string;
}
export interface UpdatePosUserRequest {
tenantid: number;
locationid: number;
user_id: number;
full_name?: string;
role?: string;
contactno?: string;
shift_id?: number;
status?: string;
}
export const posUsersApi = {
/**
* Till accounts at one outlet. `locationid` is required and singular.
*
* The envelope's `details` is an OBJECT — `{location_id, users}` — not the
* array it reads like (`posController.go:858-860`). Asking for it as a list
* returned an empty one every time, silently: the guard in `api.list` sees a
* non-array and hands back `[]`, so the page showed "no till accounts" for a
* shop that had them. Same shape trap as `/health/location`.
*/
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 : [])),
/**
* The role picker's source.
*
* Read rather than hardcoded. Supervisor is 7 and Cashier is 8 today, but the
* endpoint also carries the label and the description a person needs to
* choose between them — and a third role would appear here first.
*/
roles: () => api.list<PosRole>(`${WEB}/tenants/posroles`),
/**
* Create a till account.
*
* The response carries the PIN or password ONCE. The backend is explicit that
* a listing never returns it: "An admin who loses it reissues rather than
* looks it up." So it is shown at creation and never read back.
*/
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${WEB}/tenants/createposuser`, body),
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${WEB}/tenants/updateposuser`, body),
/**
* Shift windows a till account can be put on.
*
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
*/
/**
* 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 } : {}),
})
.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,
}),
};
/**
* Ten digits, or nothing.
*
* The till matches the mobile number EXACTLY, so `+91 98765 43210` typed back
* as `9876543210` would not find the row. Stripping to the last ten digits at
* the edge means an admin can paste whatever their contact list gave them.
*/
export function normaliseMobile(input: string): string {
const digits = input.replace(/\D/g, '');
return digits.length > 10 ? digits.slice(-10) : digits;
}
/** Mon-first mask → "Mon–Fri", "Every day". `weekdays` empty means every day. */
export function weekdayLabel(mask: string | undefined): string {
if (!mask || !/^[01]{7}$/.test(mask)) return 'Every day';
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const on = [...mask].map((bit, index) => (bit === '1' ? days[index] : null)).filter(Boolean);
if (on.length === 7) return 'Every day';
if (on.length === 0) return '—';
if (mask === '1111100') return 'Mon–Fri';
if (mask === '0000011') return 'Weekends';
return on.join(', ');
}

435
src/api/products.ts Normal file
View File

@@ -0,0 +1,435 @@
/**
* Product, stock and import endpoints.
*
* Two import paths exist and they are NOT symmetric — the asymmetry is the
* backend's, not a choice made here:
*
* Catalogue path : one batch call, idempotent. Re-importing the same
* (tenantid, brand, catalogueid) tops up stock and
* overwrites price instead of duplicating.
*
* Sheet path : `create` accepts ONE product, not an array, and does not
* return the generated productid — the controller passes the
* struct to the service by value, so GORM writes the id into
* a copy that is then discarded, and the response echoes what
* was sent. So a sheet import is N creates, then a lookup by
* SKU to resolve ids, then one batched location call and one
* batched stock call.
*
* `importSheetProducts` below encapsulates that whole dance so no screen has to
* know about it.
*/
import { api, MOB, WEB } from './client';
import type {
ImportCatalogueProductRequest,
Product,
ProductCategory,
ProductLocationRequest,
ProductStockRequest,
ProductSubCategory,
} from './types';
import { APP_BROWSE_CATEGORY } from '@/features/catalogue/tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
export interface LocationProductQuery {
tenantid: number;
locationid: number;
pageno?: number;
pagesize?: number;
}
export const productsApi = {
/**
* A store's own catalogue — what is actually imported, with live stock.
*
* `pageno` is 1-BASED on the backend: `GetLocationProducts` clamps anything
* below 1 up to 1 (`productRepository.go:453`). So page 0 and page 1 both
* return the first page, and a caller counting from zero fetches page one
* twice and never sees the last one. The `+ 1` here is what makes a 0-based
* caller correct rather than off by one.
*
* `pagesize` defaults to 200 rather than 50 because nothing in the console
* paginates this yet: both call sites ask for one page and render it, so a
* shop with 80 products was showing 50 and silently dropping the rest.
*/
locationProducts: (query: LocationProductQuery) =>
api.list<Product>(`${WEB}/products/getlocationproducts`, {
tenantid: query.tenantid,
locationid: query.locationid,
pageno: (query.pageno ?? 0) + 1,
pagesize: query.pagesize ?? 200,
}),
/**
* Every product a tenant owns, catalogue-imported or created.
*
* The payload is NOT a product list. It is `[]models.Tenantproducts` —
* `{tenant, products}` groups, one per tenant (`models/product.go:246`) — and
* it arrives under `data`, not `details`. Asked for as a flat list it handed
* back one wrapper object whose keys are `tenant` and `products`, which the
* 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
.list<{ products?: Product[] }>(`${WEB}/products/getallproducts`, { tenantid })
.then((groups) => groups.flatMap((group) => group?.products ?? [])),
count: (tenantid: number) =>
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
categories: (tenantid: number) =>
api.list<ProductCategory>(`${WEB}/products/getproductcategories`, { tenantid }),
subCategories: (tenantid: number, categoryid: number) =>
api.list<ProductSubCategory>(`${WEB}/products/getproductsubcategories`, {
tenantid,
categoryid,
}),
/** Batch, idempotent. Send the whole selection in one call. */
importFromCatalogue: (rows: ImportCatalogueProductRequest[]) =>
api.post<unknown>(`${WEB}/products/importcatalogueproduct`, rows),
/**
* 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),
/** Array. Upserts on (tenantid, locationid, productid). */
createProductLocations: (rows: ProductLocationRequest[]) =>
api.post<unknown>(`${WEB}/products/createproductlocation`, rows),
/** Array. Appends to the stock ledger. */
createProductStock: (rows: ProductStockRequest[]) =>
api.post<unknown>(`${WEB}/products/createproductstock`, rows),
/**
* Price a product and release it to the shops.
*
* There is NO `locationid` — deliberately, on the backend's side. It reads
* the tenant's active outlets itself, because "a console that sent its own
* list could publish to a subset by omission"
* (`productPublishRepository.go:50`). One call sets this price at every
* branch and also writes `products.retailprice` and `taxpercent`.
*
* Refuses `price <= 0`.
*/
publish: (body: {
tenantid: number;
productid: number;
price: number;
taxpercent: number;
}) => api.post<unknown>(`${WEB}/products/publishproduct`, body),
/**
* Clear `publishedat`. Narrower than it sounds — the till and the customer
* app do not filter on this column, so this hides the product from the store
* catalogue view and nothing else. See `Product.publishedat`.
*/
unpublish: (body: { tenantid: number; productid: number }) =>
api.post<unknown>(`${WEB}/products/unpublishproduct`, body),
/**
* The tenant's real category list, synthesised from products in use.
*
* Not `getproductcategories` — that reads a master table missing rows for
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
*/
tenantCategories: (tenantid: number) =>
api.list<{ categoryid: number; categoryname: string }>(
`${WEB}/products/gettenantcategories`,
{ tenantid },
),
/**
* Exchanges category NAMES for this tenant's category ids, creating any that
* do not exist yet.
*
* POST because it writes: a sheet naming an aisle this shop has never stocked
* opens the aisle rather than failing. Keyed on the lowercased, trimmed name,
* so a caller looks up whatever casing its own sheet used.
*/
resolveCategories: (tenantid: number, names: string[]) =>
api.post<Record<string, number>>(`${WEB}/products/resolvecategories`, { tenantid, names }),
/**
* Re-files products into different aisles, in bulk.
*
* `categoryid` should be 2 on every row — the customer app FILTERS on it and
* anything else removes the product from its browse. `subcategoryid` is the
* one that decides the heading a shopper reads; 0 leaves whatever the product
* already has, so a caller that does not know the aisle cannot erase one.
*
* Scoped by tenant on the server as well as here — a productid is global, so
* a wrong id in the list would otherwise move another merchant's product.
*/
recategorise: (
tenantid: number,
updates: { productid: number; categoryid: number; subcategoryid?: number }[],
) => api.put<{ moved: number }>(`${WEB}/products/recategorise`, { tenantid, updates }),
/** Unlinks from the store. The product row and its order history survive. */
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
api.del<unknown>(`${WEB}/products/deleteproductlocation`, body),
};
/* ────────────────────────────────────────────────────────────────────────────
The sheet-import dance
──────────────────────────────────────────────────────────────────────────── */
/** One validated row from the uploaded workbook. */
export interface SheetProductRow {
productname: string;
productsku: string;
/**
* The category NAME, from the ladder in `productCategory.ts`.
*
* The name is what the pipeline and the catalogue both speak; the id is
* per-tenant and is resolved from this at import time.
*/
category?: string;
categoryid: number;
subcategoryid: number;
retailprice: number;
productcost: number;
taxpercent: number;
quantity: number;
productunit?: string;
unitvalue?: string;
productbrand?: string;
productdesc?: string;
}
export interface SheetImportResult {
created: number;
linked: number;
stocked: number;
/** Rows the backend rejected, paired with the reason, so they can be retried. */
failures: { row: SheetProductRow; reason: string }[];
}
export interface SheetImportOptions {
tenantid: number;
locationid: number;
rows: SheetProductRow[];
onProgress?: (done: number, total: number) => void;
}
/**
* Imports a parsed sheet.
*
* NOT idempotent, and it cannot be made so from this side: nothing in the API
* dedupes on SKU, so uploading the same workbook twice creates the products
* twice. The importer UI is responsible for warning before a re-upload.
*
* Creates run sequentially rather than in parallel on purpose. There is no
* batch create, and firing 500 concurrent writes at a single-instance Go
* service to save a few seconds is a poor trade against a half-imported tenant.
*/
/**
* NO LONGER WIRED TO ANY SCREEN.
*
* The Upload sheet panel now hands the workbook to the ingest service
* (`api/ingest.ts`) instead of running this loop from the browser. Kept, not
* deleted, because the ingest contract is still unconfirmed and this is the
* known-working path back if that service turns out not to fit. Delete it once
* the ingest has run against real data and been signed off — a second import
* path that nobody calls is a thing that rots.
*/
export async function importSheetProducts(
options: SheetImportOptions,
): Promise<SheetImportResult> {
const { tenantid, locationid, rows, onProgress } = options;
const failures: SheetImportResult['failures'] = [];
const createdSkus: string[] = [];
/*
The aisles first, in one call, before a single product is created.
Every row carries a category NAME worked out by the ladder in
`productCategory.ts`. What the customer app groups by is not that category
but `products.subcategoryid` — one of ten platform rows under category 2 —
so the name is folded into an aisle and the aisle looked up by name here. See
`appAisle.ts` for the endpoint that is measured against.
A failure here is not fatal: the lookup falls back to the ids last read from
the platform, and a row that still cannot be placed is created with
subcategoryid 0, which the app lists under "Uncategorized".
*/
let aisleIds: ReadonlyMap<string, number>;
try {
aisleIds = aisleIdsFrom(await productsApi.subCategories(tenantid, APP_BROWSE_CATEGORY));
} catch {
aisleIds = aisleIdsFrom(undefined);
}
const subcategoryIdFor = (row: SheetProductRow): number =>
aisleIdForCategory(row.category, aisleIds) || Number(row.subcategoryid) || 0;
const locationRows: ProductLocationRequest[] = [];
const stockRows: ProductStockRequest[] = [];
for (const [index, row] of rows.entries()) {
try {
const created = await productsApi.createProduct({
tenantid,
productname: row.productname,
productsku: row.productsku,
// ALWAYS 2 — the app filters on it and would drop anything else. The
// aisle a shopper reads is the subcategory.
categoryid: APP_BROWSE_CATEGORY,
subcategoryid: subcategoryIdFor(row),
retailprice: row.retailprice,
productcost: row.productcost,
taxpercent: row.taxpercent,
productunit: row.productunit,
unitvalue: row.unitvalue,
productbrand: row.productbrand,
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 (locationRows.length > 0) await productsApi.createProductLocations(locationRows);
if (stockRows.length > 0) await productsApi.createProductStock(stockRows);
return {
created: createdSkus.length,
linked: locationRows.length,
stocked: stockRows.length,
failures,
};
}
/* ── Variants: one product, several sizes ────────────────────────────────── */
/**
* A size under a parent product.
*
* `variantproductid` is a REAL product row — its own price, its own stock, its
* own barcode — which is why a variant carries none of them. That is the whole
* design: "Cadbury 5 Star 18g" and "9.8g" stay two products the shop counts
* separately, and the app shows one card with a size picker.
*
* `variantname` is what the picker shows. It is free text rather than derived
* from the product name, because "Aachi Baby Fryums 500g" should read as "500g"
* in a row of three buttons, not repeat the brand three times.
*/
export interface ProductVariantLink {
variantid?: number;
tenantid: number;
/** The parent — the product the app shows. */
productid: number;
/** The product actually added to the basket for this size. */
variantproductid: number;
variantname: string;
varianttype?: string;
}
export const variantsApi = {
/**
* Group a product under a parent.
*
* The backend refuses a self-reference, a parent or child belonging to
* another tenant, and a duplicate link — so the console does not need to
* re-check any of that, only to show the reason.
*/
add: (link: ProductVariantLink) =>
api.post<ProductVariantLink>(`${WEB}/products/addproductvariant`, link),
/**
* Ungroup. The product itself is untouched — only the link goes.
*
* Keyed on `variantid`, the link's own id, not on the two product ids. The
* backend refuses anything else with "tenantid and variantid are both
* required", so the caller has to have read the link before it can drop it.
*/
remove: (params: { tenantid: number; variantid: number }) =>
api.del<unknown>(`${WEB}/products/removeproductvariant`, undefined, params),
};
/**
* The sizes under one product, as the customer app receives them.
*
* `/v1/mob`, not `/v1/web` — this endpoint exists only on the mobile group, and
* calling the web path 404s. Reading it from the console is deliberate: it is
* the only way to show a merchant exactly what a shopper will see, rather than
* a second rendering of the same links that can drift from it.
*
* The first entry is the PARENT ITSELF. A parent is one of its own sizes, so a
* picker of three has three entries, not a parent plus two.
*/
export const variantPreviewApi = {
forProduct: (params: { productid: number; tenantid: number; locationid: number }) =>
api.list<Product>(`${MOB}/products/getproductbyvariant`, params),
};

135
src/api/routing.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Real road geometry between two points.
*
* ── Why a third service ─────────────────────────────────────────────────────
*
* A straight line between two drops is not a route. On a map it cuts through
* blocks and across rivers, and its length is not the distance anybody rode —
* so a line drawn that way invites a measurement it cannot support. OSRM
* returns the actual road path, which is both honest and immediately readable
* as "they went round the one-way system".
*
* `router.project-osrm.org` is the project's own demo server. Verified reachable
* 2026-09-10 (200 in ~1.1 s for a Coimbatore leg). It is a courtesy service with
* no SLA and a fair-use policy, which shapes everything below: legs are cached,
* requests are capped per draw, and a failure is silent because a map that
* loses its road geometry is still a useful map.
*
* ── Failure is expected and must be cheap ───────────────────────────────────
*
* Every caller takes back a path or null, never an error. A null means "draw the
* straight line instead", which is what the map already did. Nothing about a
* dispatch board should break because a free routing server was busy.
*/
const OSRM_BASE = (
import.meta.env?.['VITE_OSRM_BASE'] ?? 'https://router.project-osrm.org'
)
.trim()
.replace(/\/+$/, '');
/** One leg's road geometry, as [lat, lng] pairs ready for a polyline. */
export type RoadPath = { lat: number; lng: number }[];
export interface Leg {
from: { lat: number; lng: number };
to: { lat: number; lng: number };
}
/**
* Legs already fetched, keyed on their rounded endpoints.
*
* Module-level and unbounded on purpose within a session: a dispatch board
* redraws constantly — every filter, every poll — and the same legs recur. Two
* hundred legs of geometry is a few hundred kilobytes, and re-fetching them
* from a courtesy server on each render is the behaviour that gets an IP
* blocked.
*/
const cache = new Map<string, RoadPath | null>();
/** Five decimal places is about a metre — finer than any two drops differ by. */
function keyOf(leg: Leg): string {
return `${leg.from.lat.toFixed(5)},${leg.from.lng.toFixed(5)};${leg.to.lat.toFixed(5)},${leg.to.lng.toFixed(5)}`;
}
/** A single leg's road path, or null when it cannot be had. */
async function fetchLeg(leg: Leg, signal?: AbortSignal): Promise<RoadPath | null> {
const url =
`${OSRM_BASE}/route/v1/driving/` +
`${leg.from.lng},${leg.from.lat};${leg.to.lng},${leg.to.lat}` +
`?overview=full&geometries=geojson`;
try {
const response = await fetch(url, { signal });
if (!response.ok) return null;
const body = (await response.json()) as {
routes?: { geometry?: { coordinates?: [number, number][] } }[];
};
const coordinates = body.routes?.[0]?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
// GeoJSON is [lng, lat]; leaflet wants lat first. Getting this backwards
// puts Coimbatore in the Arabian Sea, which is the classic symptom.
return coordinates.map(([lng, lat]) => ({ lat, lng }));
} catch {
// Includes the abort. A cancelled draw wants no path, same as a failed one.
return null;
}
}
/**
* How many legs one draw may ask for.
*
* A hundred-drop round is ninety-nine legs, and asking a courtesy server for
* all of them at once is how a shared IP gets rate-limited for everybody. Past
* this the map falls back to straight lines, which it can always draw.
*/
const MAX_LEGS_PER_DRAW = 60;
/** How many requests are in flight at once. Polite, and enough to feel instant. */
const CONCURRENCY = 4;
export const routingApi = {
/**
* Road geometry for a set of legs.
*
* Returns a map keyed the same way the caller can look up — `keyFor(leg)` —
* holding a path or null per leg. Cached legs cost nothing; uncached ones are
* fetched a few at a time.
*
* Never throws. A leg that could not be routed is absent from the result and
* the caller draws its straight line, which is what it did before.
*/
roads: async (legs: readonly Leg[], signal?: AbortSignal): Promise<Map<string, RoadPath>> => {
const out = new Map<string, RoadPath>();
const wanted: Leg[] = [];
for (const leg of legs) {
const key = keyOf(leg);
if (cache.has(key)) {
const hit = cache.get(key);
if (hit) out.set(key, hit);
} else if (wanted.length < MAX_LEGS_PER_DRAW) {
wanted.push(leg);
}
}
for (let i = 0; i < wanted.length; i += CONCURRENCY) {
if (signal?.aborted) break;
const batch = wanted.slice(i, i + CONCURRENCY);
const paths = await Promise.all(batch.map((leg) => fetchLeg(leg, signal)));
batch.forEach((leg, index) => {
const key = keyOf(leg);
const path = paths[index] ?? null;
// Null is cached too: a leg the router cannot do will not start working
// if it is asked sixty more times this session.
cache.set(key, path);
if (path) out.set(key, path);
});
}
return out;
},
/** The key a leg's path is stored under, for callers reading the result. */
keyFor: keyOf,
};

193
src/api/stock.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Stock requests and stock movement — the Store Admin's approval surface.
*
* Read `store-admin-backend-gap.md` §2.2 before extending this file. The
* approval workflow the spec describes does not exist in Fiesta: the request
* table has no reason, requester, approved quantity, approver or remarks, and
* `UpdateStockRequest(requestID, status)` takes a bare string. Exactly one
* value does anything — "Received" — and it posts a stock movement for the FULL
* requested quantity, guarded against double-receiving.
*
* So "approve for a different quantity" is not implemented here because it
* cannot be implemented here. It is a backend change, not a frontend one.
*/
import { api, WEB } from './client';
import type { StockRequest, StockStatementRow } from './types';
/**
* The status values the backend actually distinguishes.
*
* `Received` is the only one with behaviour. The others are stored verbatim and
* read back, which is enough to drive a queue but is not a state machine — the
* backend will accept any string at all.
*/
/**
* The ladder a request climbs.
*
* `Approved` sits between asking and arriving, and adding it is the point:
* approving used to put the stock on the shelf immediately, so the count said
* the goods were there from the moment the admin agreed to send them — which is
* days before they arrive, and the branch sells against a shelf that is empty.
*
* Only `Received` moves the ledger. Fiesta keys the stock write on that exact
* word, so `Approved` is a status and nothing else.
*/
export const STOCK_REQUEST_STATUS = {
pending: 'Pending',
approved: 'Approved',
received: 'Received',
rejected: 'Rejected',
} as const;
export type StockRequestStatus = (typeof STOCK_REQUEST_STATUS)[keyof typeof STOCK_REQUEST_STATUS];
export interface StockRequestQuery {
tenantid: number;
/** Omit for every branch. */
locationid?: number;
status?: string;
date?: string;
pageno?: number;
pagesize?: number;
}
/**
* What a batch actually did.
*
* Both lists are always read: a batch that half-worked is the case worth
* reporting, and the failures name the row so somebody can go and look.
*/
export interface StockBatchOutcome {
updated?: number[];
created?: unknown[];
failed?: { requestid?: number; productid?: number; reason: string }[];
}
export interface CreateStockRequest {
tenantid: number;
locationid: number;
productid: number;
qty: number;
/** Carried so the admin's queue can name the branch without a second read. */
locationname?: string;
productname?: string;
}
export const stockApi = {
/**
* A branch asks its admin for stock.
*
* The only write a Store user has against inventory, and deliberately so:
* nothing here moves the ledger. `status` is always Pending — the backend
* defaults to it when blank, but sending it makes the intent explicit rather
* than relying on a default that a later release could change.
*
* There is no reason field, no requester and no wanted-by date in
* `stockrequests`, so the request carries a product and a quantity and
* nothing else. Do not invent the rest in the UI.
*/
create: (body: CreateStockRequest) =>
api.post<StockRequest>(`${WEB}/products/createstockrequest`, {
...body,
status: STOCK_REQUEST_STATUS.pending,
}),
requests: (query: StockRequestQuery) =>
api.list<StockRequest>(`${WEB}/products/getstockrequests`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
date: query.date,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 50,
}),
/**
* Agree to send the stock. Nothing reaches the shelf yet.
*
* The shelf is written when the branch confirms the goods ARRIVED, not when
* the admin agrees to send them — see `confirmArrival`.
*/
approve: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.approved,
}),
/**
* The goods turned up. THIS is what adds `request.qty` to the branch's stock.
*
* There is no way to receive a different amount: the service reads the
* quantity off the request row, not off this call. A short delivery has to be
* corrected on the stock ledger afterwards.
*/
confirmArrival: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.received,
}),
/**
* Several requests at once.
*
* One call rather than a loop of them, because approving MOVES STOCK: a loop
* that dies halfway leaves some deliveries received and some not, with nothing
* to say which. The backend applies each id separately and reports both lists,
* so a partial outcome is a fact the screen can show rather than a guess.
*
* The same status for the whole batch, never a mix. "Approve these" and
* "reject these" are two decisions, and one call that could do both is how a
* mis-click approves what it meant to refuse.
*/
decideMany: (requestids: number[], status: StockRequestStatus) =>
api.put<StockBatchOutcome>(`${WEB}/products/updatestockrequest`, { requestids, status }),
approveMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.approved),
confirmArrivalMany: (requestids: number[]) =>
stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.received),
rejectMany: (requestids: number[]) => stockApi.decideMany(requestids, STOCK_REQUEST_STATUS.rejected),
/**
* A branch asks for several products in one go.
*
* Restocking after a delivery is one errand, not twenty. Sending it as twenty
* calls is slow, and a dropped connection leaves a half-made request list that
* nobody can tell apart from a deliberate one.
*/
createMany: (rows: CreateStockRequest[]) =>
api.post<StockBatchOutcome>(`${WEB}/products/createstockrequest`,
rows.map((row) => ({ ...row, status: STOCK_REQUEST_STATUS.pending }))),
/** Reject — a status write and nothing else. No stock moves, no reason stored. */
reject: (requestid: number) =>
api.put<unknown>(`${WEB}/products/updatestockrequest`, {
requestid,
status: STOCK_REQUEST_STATUS.rejected,
}),
/**
* Stock movement for one branch.
*
* Opening, credit, debit, closing — and that is the whole vocabulary.
* `productstocks.stocktype` is `in`/`out`, so a sale, a transfer and a
* correction are indistinguishable once written. The spec's seven movement
* types (§3.8) cannot be sourced; see gap §2.5.
*/
statement: (params: {
tenantid: number;
locationid: number;
subcategoryid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) =>
api.list<StockStatementRow>(`${WEB}/products/getstockstatement`, {
tenantid: params.tenantid,
locationid: params.locationid,
subcategoryid: params.subcategoryid,
keyword: params.keyword,
pageno: params.pageno ?? 0,
pagesize: params.pagesize ?? 100,
}),
};

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

319
src/api/tenants.ts Normal file
View File

@@ -0,0 +1,319 @@
/** Tenant and branch endpoints — the Nearle Admin's provisioning surface. */
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
export interface CreateTenantRequest {
tenantname: string;
companyname: string;
primarycontact: string;
primaryemail: string;
/**
* Who runs the shop — `tenants.firstname`.
*
* Asked here because here is the only moment it can be asked. The primary
* branch and the merchant's own admin login are both created inside
* `CreateTenantUser`'s transaction, and the login is copied from the tenant
* row — so a name given now names the business AND the account that will
* sign in. Left out, both are blank, which is what every merchant on the
* platform currently has: the store profile prints "—" for Store admin and
* the account carries no person's name at all.
*
* One field, not two: `tenants` has a `firstname` column and no
* `lastname`, so this is the whole name.
*/
firstname?: string;
locationname: string;
categoryid: number;
subcategoryid?: number;
address: string;
suburb?: string;
city: string;
state: string;
postcode: string;
latitude?: string;
longitude?: string;
moduleid?: number;
/** The city this merchant trades in. `app_location`, not a branch. */
applocationid?: number;
status?: string;
}
/** 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;
address: string;
suburb?: string;
city: string;
state: string;
postcode: string;
latitude?: string;
longitude?: string;
opentime?: string;
closetime?: string;
deliveryradius?: number;
deliverymins?: number;
status?: string;
/**
* Who will run this outlet — an existing person, when one has been hired
* already.
*
* Omitted, the backend spawns a login named after the SHOP, on the shop's
* email address, one per outlet. That was the only option, and it is why two
* people at a counter shared a credential and nothing recorded which of them
* did anything.
*
* A branch must still arrive with SOMEBODY: name a person here, or give an
* `email` to spawn one from. The backend refuses a branch with neither,
* because an outlet nobody can sign in to is a dead end that shows up in
* every list and is noticed by whoever is standing in the shop.
*/
operatorid?: number;
}
export interface TenantListQuery {
pageno?: number;
pagesize?: number;
/** `Active` / `InActive`. Omitted, the backend returns every state. */
status?: string;
applocationid?: number;
tenanttype?: string;
keyword?: string;
}
export const tenantsApi = {
/**
* Every tenant on the platform. Deliberately unscoped — this is the
* Nearle Admin's list, and the backend treats it as the platform-operator
* endpoint rather than a tenant-scoped one.
*/
listAll: (query: TenantListQuery = {}) =>
api.list<TenantInfo>(`${WEB}/tenants/getalltenants`, {
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
status: query.status,
applocationid: query.applocationid,
tenanttype: query.tenanttype,
keyword: query.keyword,
}),
/**
* Tenants by approval state — the only way to see the ones awaiting it.
*
* `status=pending` is not a status at all: the handler branches on the word
* and queries `approved = 0` instead (`tenantRepository.go:45-77`). Anything
* else means `approved = 1 AND status = ?`. So an unapproved merchant is
* invisible to every other endpoint, including `getalltenants`.
*
* Nothing can approve one over HTTP. `approved` is writable only at creation,
* so this list is a queue to work from, not one to act on.
*/
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
api.list<TenantInfo>(`${WEB}/tenants/search`, { status, keyword }),
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
locations: (tenantid: number) =>
api.list<TenantLocation>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
search: (keyword: string) =>
api.list<TenantInfo>(`${WEB}/tenants/searchbykeyword`, { keyword }),
/**
* Provisions the enterprise, its first outlet, and the primary Administrator
* account — one transaction writing `tenants`, `ordersequences`, `app_users`
* (roleid 1, configid forced to 1), `customers`, `customerlocations` and
* `tenantcustomers`.
*
* `createtenantuser`, NOT `createtenantlocation`. The latter takes a
* `Tenantlocations` and writes a BRANCH under a tenant that already exists —
* pointing the merchant form at it created an outlet and no merchant.
*
* The primary outlet is NESTED. The backend reads it off
* `Tenants.Tenantlocations` and creates it in the same transaction, so a
* tenant can never exist without somewhere to trade from.
*/
createTenant: (body: CreateTenantRequest) =>
api.post<TenantInfo>(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
/**
* Commissions a branch, and gives it somebody to run it.
*
* Pass `operatorid` to place a person you have already hired. Without it the
* backend spawns a login named after the shop, as it always did — kept so
* nothing existing changes, but the named person is the better path.
*
* `createtenantlocation`, not `createlocation`: only this one returns the
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),
/**
* A merchant editing their own business record.
*
* The first write path `tenants` has ever had. Before it, everything about a
* shop — its name, its photograph, its licence, how to reach it — was set
* once at onboarding by a Nearle Admin and could never be changed by anyone.
*
* Only merchant-owned columns are written; the backend keeps the allowlist
* and ignores the rest, so `approved`, `status`, `partnerid` and the billing
* fields cannot be set from here even if a caller sends them. Anything
* omitted is left alone rather than blanked.
*/
updateProfile: (body: { tenantid: number } & Partial<TenantInfo>) =>
api.put<unknown>(`${WEB}/tenants/updatetenant`, body),
/**
* Which delivery partner supplies this merchant's riders.
*
* Its own endpoint, not a field on `updateProfile`: `partnerid` is kept out
* of the merchant-editable allowlist on purpose, because a merchant who could
* set it would move themselves under another partner's riders and billing.
*
* `partnerid: 0` is a real instruction — it means "this merchant uses their
* own riders" — and the server reads it as sent rather than as absent.
*/
assignPartner: (tenantid: number, partnerid: number) =>
api.put<unknown>(`${WEB}/tenants/assignpartner`, { tenantid, partnerid }),
/**
* One business, by id — how a store login reads its own record.
*
* Not `listAll`. That is `getalltenants`, paginated over 262 merchants, so a
* shop on page two was simply absent and a profile screen built on it would
* show nothing for no visible reason.
*/
byId: (tenantid: number) => api.get<TenantInfo>(`${WEB}/tenants/gettenantinfo`, { tenantid }),
/**
* Somebody editing their own name, mobile or email.
*
* Not `users/update`. That one writes whatever struct it is handed and checks
* only the userid — no tenant, no guard on role or branch — so a self-service
* form built on it would let a branch user promote themselves or move shop.
* This is scoped to the caller's own account AND business, and writes
* identity fields only.
*/
updateOwnProfile: (body: {
userid: number;
tenantid: number;
firstname?: string;
lastname?: string;
contactno?: string;
email?: string;
}) => api.put<unknown>(`${WEB}/tenants/updateownprofile`, body),
};
/**
* The merchant form, in the shape `models.Tenants` expects.
*
* `configid` and `applocationid` are sent because the account this call spawns
* is looked up by `configid` at every sign-in, and `applocationid` is the city
* the tenant trades in. `approved: 1` and `status: 'Active'` are set here
* because they can only ever be set here — there is no update or approve
* endpoint, so a tenant created unapproved stays unapproved forever.
*/
function toTenantBody(form: CreateTenantRequest): Record<string, unknown> {
return {
tenantname: form.tenantname,
companyname: form.companyname,
primaryemail: form.primaryemail,
primarycontact: form.primarycontact,
// Written to `tenants.firstname` and copied onto the admin login the same
// transaction creates — see the note on the field.
firstname: (form.firstname ?? '').trim(),
categoryid: form.categoryid,
subcategoryid: form.subcategoryid ?? 0,
address: form.address,
suburb: form.suburb ?? '',
city: form.city,
state: form.state,
postcode: form.postcode,
latitude: form.latitude ?? '',
longitude: form.longitude ?? '',
configid: 1,
moduleid: form.moduleid ?? 2,
applocationid: form.applocationid ?? 1,
approved: 1,
status: form.status ?? 'Active',
// The primary outlet, created in the same transaction. Its address
// defaults to the tenant's — a merchant's first shop is at the address
// they just typed far more often than not, and it can be edited after.
tenantlocations: {
locationname: form.locationname,
email: form.primaryemail,
contactno: form.primarycontact,
address: form.address,
suburb: form.suburb ?? '',
city: form.city,
state: form.state,
postcode: form.postcode,
latitude: form.latitude ?? '',
longitude: form.longitude ?? '',
applocationid: form.applocationid ?? 1,
status: 'Active',
},
};
}
/** One row of the `app_category` master — the business categories a tenant picks from. */
export interface AppCategory {
categoryid: number;
categoryname: string;
}
export const utilsApi = {
/**
* The business-category master.
*
* Read rather than hardcoded. The old console typed four values into the
* form and never called this, which means a category added to the master is
* invisible to onboarding until someone edits the frontend.
*/
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
/**
* The delivery regions — Coimbatore, Madurai, Nagercoil today.
*
* `applocationid` is REQUIRED by the handler and 0 is how you ask for all of
* them; omitting it answers 400 "Invalid applocationid", which reads as a
* broken request rather than a missing default.
*/
appLocations: (applocationid = 0) =>
api.list<AppLocation>(`${WEB}/utils/getapplocations`, { applocationid }),
};

1003
src/api/types.ts Normal file

File diff suppressed because it is too large Load Diff

100
src/api/uploads.test.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* The receipt, and the two things it has to get right.
*
* A receipt exists because the ingest service's batch id is the only credential
* for reading a result back, it is handed out once, and an unreviewed drop is
* deleted after seven days. So the tests that matter are about not losing that
* window, and about the label their admin reads when deciding whether to
* approve the file.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildSender, daysUntilExpiry, DROP_RETENTION_DAYS, type UploadReceipt } from './uploads';
function receipt(overrides: Partial<UploadReceipt> = {}): UploadReceipt {
return {
uploadid: 1,
tenantid: 1141,
locationid: 1180,
categoryid: 2,
batchid: '49a82536866a483a9189954d3c749243',
runid: '',
filename: 'kmart-opening.xlsx',
sender: 'Kmart · Peelamedu · abhishek',
uploadedby: 1475,
uploadedname: 'abhishek',
rowcount: 20,
laststatus: 'pending',
inserted: 0,
backfilled: 0,
skipped: 0,
rejected: 0,
shelvedcount: 0,
skippedcount: 0,
shelvedat: null,
created: new Date().toISOString(),
updated: new Date().toISOString(),
...overrides,
};
}
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
test('a drop uploaded today has the full window left', () => {
assert.equal(daysUntilExpiry(receipt()), DROP_RETENTION_DAYS);
});
test('the window closes as the drop sits unreviewed', () => {
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(5) })), 2);
});
// Never negative. A drop past the deadline is gone, and "-3 days left" would
// read as a countdown that is still running.
test('an expired drop reports zero, not a negative', () => {
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(30) })), 0);
});
/*
Retention applies to a drop nobody acted on. Once an admin releases it the run
is the record and the drop's own expiry is irrelevant — showing a countdown on
a released upload would push someone to chase a deadline that has already been
met.
*/
test('a released drop has no expiry to report', () => {
assert.equal(daysUntilExpiry(receipt({ runid: '8dcef8a2ad94', laststatus: 'retired' })), null);
assert.equal(daysUntilExpiry(receipt({ laststatus: 'done' })), null);
});
test('a receipt with an unreadable date reports nothing rather than guessing', () => {
assert.equal(daysUntilExpiry(receipt({ created: 'not a date' })), null);
});
/*
The sender label. Free text on their side, capped at 60 characters, and shown to
the admin who decides whether to run the file — so it has to identify the shop,
not the console.
*/
test('the sender names the merchant, the branch and the person', () => {
assert.equal(
buildSender({ tenantname: 'Kmart', locationname: 'Peelamedu', username: 'abhishek' }),
'Kmart · Peelamedu · abhishek',
);
});
// The tenant name is truncated, not the whole label. Cutting the tail would
// drop the branch and the person — the two parts that say WHICH shelf and WHO —
// and leave only a long restaurant name that identifies neither.
test('a long merchant name is trimmed so the branch and person survive', () => {
const sender = buildSender({
tenantname: 'Ninhao The New Age Chinese Restaurant',
locationname: 'Race Course',
username: 'abhishek',
});
assert.ok(sender.length <= 60, `sender was ${sender.length} chars: ${sender}`);
assert.ok(sender.includes('Race Course'), sender);
assert.ok(sender.includes('abhishek'), sender);
});
test('a sender with nothing to say still labels the console', () => {
assert.equal(buildSender({}), 'nearle-console');
});

220
src/api/uploads.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Receipts for spreadsheets sent to the catalogue ingest service.
*
* This is OUR record, in Fiesta — not the ingest service's. The two are read
* together and neither is redundant:
*
* - **Fiesta** knows the batch id, which shop the sheet was for, who sent it,
* and whether the products reached that shop's shelf. None of which the
* ingest service has any concept of — it writes the shared global
* catalogue and has no tenant and no branch.
* - **The ingest service** knows what became of the drop, and is the only
* authority on that.
*
* Fiesta's status columns are a CACHE of the second, written by whichever
* browser last polled. They exist so a list of twenty receipts renders without
* twenty network calls to a host that spends minutes per batch; the live read
* is what any single receipt is judged by.
*
* ── Why the receipt has to exist at all ──────────────────────────────────────
*
* Three facts from the ingest service's own documentation, and any one of them
* would be enough:
*
* 1. **The batch id is the credential.** `GET /api/uploads/catalog/{id}` is
* anonymous by design — holding the id is the proof of having sent the
* drop. Handed out once, to one browser. Lose it and the result is
* unreadable by anyone, including whoever uploaded the file.
* 2. **An unreviewed drop is deleted after seven days.** Nothing runs on
* arrival; a drop waits for an admin to press Start. If nobody does, the
* evidence expires.
* 3. **We cannot list our own drops.** `GET /api/uploads/catalog` is scoped to
* the credential that sent them, production has no API keys configured at
* all, and the only account that could read it is a superuser over their
* entire application.
*/
import { api, WEB } from './client';
/**
* One upload, as Fiesta stores it.
*
* The two count groups are deliberately not merged. `inserted` and its
* neighbours are the ingest service's — products in the GLOBAL catalogue, which
* every merchant shares and which therefore carries no price and no stock.
* `shelvedcount` is ours: priced, on a branch's shelf, with opening stock
* recorded. A product can be in the first and not the second, and reporting it
* as "added" would tell a shopkeeper they can sell something nobody can buy.
*/
export interface UploadReceipt {
uploadid: number;
tenantid: number;
locationid: number;
categoryid: number;
/** The drop id. The only field here that cannot be reconstructed. */
batchid: string;
/** The run an admin released the drop into, once they have. */
runid: string;
filename: string;
/** The label their review inbox shows. */
sender: string;
uploadedby: number;
uploadedname: string;
/** Rows we parsed before sending — independent of anything the service says. */
rowcount: number;
/**
* The parsed sheet as JSON, or empty when it was never stored.
*
* Empty is the interesting case: it means the prices and opening stock are
* gone, and the only way to shelve this upload is for somebody to hand the
* file over again. Receipts written before this column existed are all in
* that state.
*/
sheetrows?: string;
/* ── Cached from the ingest service ──────────────────────────────────── */
laststatus: string;
inserted: number;
backfilled: number;
skipped: number;
rejected: number;
/* ── Ours ────────────────────────────────────────────────────────────── */
shelvedcount: number;
skippedcount: number;
shelvedat: string | null;
created: string;
updated: string;
/** Joined for display; a receipt outlives the page that made it. */
tenantname?: string;
locationname?: string;
}
export interface RecordUploadBody {
/**
* The parsed sheet as JSON — SKU, price and opening stock per row.
*
* Stored so the shelving step can run later, from the Uploads page, without
* the original file or the tab that sent it. The prices and opening stock
* exist nowhere else: the ingest service's catalogue is shared by every
* merchant and carries neither.
*/
sheetrows?: string;
tenantid: number;
locationid: number;
categoryid: number;
batchid: string;
filename: string;
sender: string;
uploadedby: number;
uploadedname: string;
rowcount: number;
laststatus?: string;
}
export interface UploadQuery {
/** 0 or omitted means every tenant — how a Nearle Admin sees the platform. */
tenantid?: number;
locationid?: number;
pageno?: number;
pagesize?: number;
}
/**
* How long the ingest service keeps a drop nobody has acted on.
*
* `BATCH_RETENTION_DAYS` on their side. Worth showing rather than discovering:
* a drop that expires unreviewed leaves no trace at either end, and the only
* remedy — asking an admin to release it — has to happen before the deadline.
*/
export const DROP_RETENTION_DAYS = 7;
/** Days left before an unreviewed drop is deleted; null once it has run. */
export function daysUntilExpiry(receipt: UploadReceipt): number | null {
// Only a drop still sitting in the review inbox expires. Once released, the
// run is the record and retention no longer applies to it.
if (receipt.runid || receipt.laststatus !== 'pending') return null;
const created = Date.parse(receipt.created);
if (Number.isNaN(created)) return null;
const elapsedDays = (Date.now() - created) / 86_400_000;
return Math.max(0, Math.ceil(DROP_RETENTION_DAYS - elapsedDays));
}
/**
* The label the ingest service's admin sees in their review inbox.
*
* Their field is free text capped at 60 characters, and until now every upload
* from this console arrived as the same constant — so an admin deciding what to
* approve could not tell one merchant's sheet from another's.
*
* Safe to make specific precisely because we never send a credential. Their
* ownership filter matches `sender` EXACTLY, and a run an admin assembles from
* several drops carries a joined list ("alice, bob") — so a credentialed caller
* gets a 404 on a run containing their own file. We read anonymously, holding
* the id, which is what their documentation tells integrators to do.
*
* The tenant name is truncated rather than the whole label, so the branch and
* the person survive: "Ninhao The New Age Chinese Restaurant" is 36 characters
* on its own and would otherwise push everything identifying off the end.
*/
export function buildSender(parts: {
tenantname?: string;
locationname?: string;
username?: string;
}): string {
const tenant = (parts.tenantname ?? '').trim().slice(0, 24);
const label = [tenant, (parts.locationname ?? '').trim(), (parts.username ?? '').trim()]
.filter(Boolean)
.join(' · ');
return (label || 'nearle-console').slice(0, 60);
}
export const uploadsApi = {
/**
* Store the receipt. Called the instant the drop is accepted, before polling.
*
* That timing is the whole point: it is the one moment the batch id is
* guaranteed to exist and guaranteed not to have been lost to a closed tab.
* Idempotent on `batchid` server-side, so a retry or a second tab is safe.
*/
record: (body: RecordUploadBody) => api.post<UploadReceipt>(`${WEB}/uploads/record`, body),
list: (query: UploadQuery = {}) =>
api.list<UploadReceipt>(`${WEB}/uploads/list`, {
tenantid: query.tenantid ?? 0,
locationid: query.locationid ?? 0,
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 50,
}),
/** Cache what the ingest service last reported, so the next reader need not wait. */
updateStatus: (body: {
batchid: string;
laststatus: string;
runid?: string;
inserted?: number;
backfilled?: number;
skipped?: number;
rejected?: number;
}) => api.put<unknown>(`${WEB}/uploads/update`, body),
/**
* Supply the prices and opening stock for a receipt that has none.
*
* The rescue path. A receipt filed before the sheet was stored cannot be
* shelved from anywhere, because the ingest service holds a catalogue every
* merchant shares and it carries neither figure — so the file has to come
* back. Sent as JSON so the shelving can then run without it again.
*/
attachSheet: (body: { batchid: string; sheetrows: string }) =>
api.put<unknown>(`/uploads/sheet`, body),
/** Record the other half: priced, shelved and stocked at a branch. */
markShelved: (body: { batchid: string; shelved: number; skipped: number }) =>
api.put<unknown>(`${WEB}/uploads/shelved`, body),
};

52
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { HOME_ROUTE, type ConsoleRole, type SessionUser } from './roles';
import { clear, login as loginRequest, restore } from './session';
import { AuthContext, useAuth } from './context';
/**
* The context object and `useAuth` live in `./context`, which exports no
* components — see the note there. Both are re-exported from here so every
* existing import keeps working; the split is invisible to callers.
*/
export { useAuth } from './context';
export type { AuthContextValue } from './context';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(() => restore());
const signIn = useCallback(async (email: string, password: string) => {
const session = await loginRequest(email, password);
setUser(session);
return session;
}, []);
const signOut = useCallback(() => {
clear();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, signIn, signOut }),
[user, signIn, signOut],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
/**
* Route guard.
*
* A role that reaches a workspace it does not own is redirected to its own home
* rather than shown an error — the same partition the old console enforces, and
* the same one the three logins imply.
*/
export function RequireRole({ role, children }: { role: ConsoleRole; children: ReactNode }) {
const { user } = useAuth();
const location = useLocation();
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
if (user.role !== role) return <Navigate to={HOME_ROUTE[user.role]} replace />;
return <>{children}</>;
}

45
src/auth/context.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createContext, use } from 'react';
import type { SessionUser } from './roles';
/**
* The auth context object, kept in a module that exports NO components.
*
* That separation is the whole point of this file, and it is not style.
*
* `createContext()` returns an object whose IDENTITY is the key React matches a
* provider to a consumer by. React Fast Refresh re-executes a module when it or
* its dependents change, and a module that exports components is a refresh
* boundary — so while `AuthContext` lived beside `AuthProvider`, a refresh could
* mint a NEW context object for the provider while consumers that were not
* re-executed still held the OLD one. The provider then publishes into a
* context nobody is reading, `use(AuthContext)` returns null, and `useAuth`
* throws `useAuth must be used inside <AuthProvider>` — from a component that
* is unmistakably inside it.
*
* That error is a lie about the component tree, which is what makes it so
* expensive: it sends you looking at `main.tsx`, where the nesting is correct
* and always was. A file with no component exports is not a refresh boundary,
* so the object created here is created once per page load and cannot be
* duplicated by an edit anywhere else.
*
* Rule for this file: no components, ever. Adding one re-arms the bug.
*/
export interface AuthContextValue {
user: SessionUser | null;
signIn: (email: string, password: string) => Promise<SessionUser>;
signOut: () => void;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const context = use(AuthContext);
if (!context) {
throw new Error(
'useAuth must be used inside <AuthProvider>. If the tree looks right, the dev server ' +
'is serving a stale module — stop it, delete node_modules/.vite, and start it again.',
);
}
return context;
}

58
src/auth/roles.test.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* Which workspace a roleid lands in — and the one that has been mislabelled on
* every shop since the platform started.
*
* `app_roles` calls roleid 1 "Super admin", and tenant onboarding wrote 1 for a
* merchant's own administrator. So every shop's Users & access screen listed
* its owner as a platform operator. It never WAS one — platform access is
* `app_users.issuperadmin`, a separate column checked first — but the label is
* the sort of thing somebody eventually acts on.
*
* New tenants get roleid 3 ("Admin"). Existing ones keep 1, and must keep
* working: nine shops were provisioned with it.
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveRole } from './roles';
test('platform access comes from issuperadmin, never from a roleid', () => {
assert.equal(resolveRole({ roleid: 0, issuperadmin: true }), 'nearle-admin');
// The point of the whole fix: roleid 1 is a merchant, not a platform operator.
assert.equal(resolveRole({ roleid: 1, issuperadmin: false }), 'store-admin');
});
test('a merchant administrator reaches the Store Admin workspace', () => {
// 3 is what new tenants get; 1 is what every existing tenant has.
assert.equal(resolveRole({ roleid: 3, issuperadmin: false }), 'store-admin');
assert.equal(resolveRole({ roleid: 1, issuperadmin: false }), 'store-admin');
});
/*
Existing merchants must not be locked out. Nine shops were provisioned with
roleid 1 before this changed, and dropping it from the store-admin set would
shut every one of their owners out of their own console.
*/
test('roleid 1 keeps working, so no existing merchant is locked out', () => {
assert.notEqual(resolveRole({ roleid: 1, issuperadmin: false }), 'store-manager');
});
test('a manager is pinned to one shop', () => {
// 4 is "Manager" in app_roles, and what the old console gave rmartuser.
assert.equal(resolveRole({ roleid: 4, issuperadmin: false }), 'store-manager');
});
// The auto-spawned branch login carries roleid 0 — Go's zero value, and the
// branch-user role. It must land in the shop workspace, not the merchant's.
test('the branch login lands in the store workspace', () => {
assert.equal(resolveRole({ roleid: 0, issuperadmin: false }), 'store-manager');
});
/*
Till accounts must never reach a back-office workspace. They are excluded in the
backend's queries too — a cashier is "not found" rather than "refused" — but a
roleid arriving from anywhere else must not resolve upward.
*/
test('till roles never resolve to a merchant workspace', () => {
assert.equal(resolveRole({ roleid: 7, issuperadmin: false }), 'store-manager');
assert.equal(resolveRole({ roleid: 8, issuperadmin: false }), 'store-manager');
});

128
src/auth/roles.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Role resolution.
*
* The backend issues no session token: the login endpoints look the user up and
* return the record. So "signed in" here means "we hold a verified user object",
* and the role is DERIVED from that record rather than asserted by the client.
*
* When the backend does start issuing tokens, this file and `session.ts` are
* the only two that should need to change.
*/
import type { FiestaUser } from '@/api/types';
export type ConsoleRole = 'nearle-admin' | 'store-admin' | 'store-manager';
/**
* Roleids that reach the Store Admin workspace.
*
* 7 (Supervisor) and 8 (Cashier) must NEVER appear here: they are till roles,
* and a cashier landing in the tenant console is a privilege escalation, not a
* cosmetic bug.
*/
const STORE_ADMIN_ROLE_IDS: ReadonlySet<number> = new Set([1, 3]);
/** Till-only roles, listed so the exclusion is explicit rather than implied. */
export const TILL_ROLE_IDS: ReadonlySet<number> = new Set([7, 8]);
export interface SessionUser {
userid: number;
role: ConsoleRole;
name: string;
email: string;
roleid: number;
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;
}
/**
* `issuperadmin` is checked FIRST because it is server-derived. A roleid cannot
* be trusted to imply platform access, so the flag wins over the numeric split.
*/
export function resolveRole(user: Pick<FiestaUser, 'roleid' | 'issuperadmin'>): ConsoleRole {
if (user.issuperadmin === true) return 'nearle-admin';
if (STORE_ADMIN_ROLE_IDS.has(user.roleid)) return 'store-admin';
return 'store-manager';
}
export function toSessionUser(user: FiestaUser): SessionUser {
const name = [user.firstname, user.lastname].filter(Boolean).join(' ').trim();
/**
* Trimmed, and that is load-bearing.
*
* `fullname` is not a column — `GetTenantUserById` builds it as
* `concat(a.firstname,' ',a.lastname)` (`userRepository.go:249`). For an
* account with neither name filled in, that concat produces a SINGLE SPACE,
* not an empty string, and a single space is truthy. So the fallback chain
* below stopped here and handed the app a name of " ", which then rendered as
* an avatar with no initials in it. Trimming lets it fall through to the
* email, which every account has.
*/
const fullname = (user.fullname ?? '').trim();
return {
userid: user.userid,
role: resolveRole(user),
name: name || fullname || user.authname || user.email,
email: user.email,
roleid: user.roleid,
tenantid: user.tenantid,
locationid: user.locationid,
issuperadmin: user.issuperadmin === true,
};
}
/** Where each role lands when it has nowhere more specific to go. */
/**
* Where each role lands.
*
* These MUST be paths that actually resolve. The global `*` route redirects
* here, so a HOME_ROUTE pointing at a path with no matching route sends the
* router straight back to `*`, which sends it here again: an infinite redirect
* that React Router resolves by rendering nothing at all. It fails as a blank
* page with no console error, which is the worst way for a routing bug to
* present. `/admin/dashboard` did exactly that — it was the old console's name
* for the page this one calls Console.
*
* Each workspace also carries its own catch-all in `App.tsx`, so a wrong
* sub-path is absorbed there and never reaches the global one.
*/
export const HOME_ROUTE: Record<ConsoleRole, string> = {
'nearle-admin': '/nearle/stores',
// `/admin/console` and `/store/console` are not routes in this application —
// merchants have their own, `nearle-console`. Pointing at them would be 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.
//
// Neither can be reached today, because `login` and `restore` refuse both
// roles before a session exists. `/login` rather than those unreachable paths
// so that if either check is ever weakened, the result is a sign-in screen
// rather than a white screen nobody can diagnose.
'store-admin': '/login',
'store-manager': '/login',
};
export const ROLE_LABEL: Record<ConsoleRole, string> = {
'nearle-admin': 'Nearle Admin',
'store-admin': 'Store Admin',
// The backend's own word for it: an `app_users` row with roleid 0, bound to
// one `tenantlocations.locationid`. "Manager" implied a rank the record does
// not carry.
'store-manager': 'Store user',
};

113
src/auth/session.test.ts Normal file
View File

@@ -0,0 +1,113 @@
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: 'nearle-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: 'nearle-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: 'nearle-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":"nearle-admin","token":"t"}',
'{"userid":904,"token":"t"}',
'{"userid":"904","role":"nearle-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.
This console admits Nearle staff only. Merchants and their branch users sign in
at the merchant console, which is a separate application. The role is not known
until the password has been checked, so the refusal happens after credentials are
verified — which makes the ORDER of refusal and 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 merchant session is not restored here', () => {
// Both merchant roles, because the refusal must cover the whole other
// console and not just the one that was tested first. A session like this
// reaches storage when somebody used this browser for the merchant console,
// or from a build that predates the split.
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(), null, `a ${role} session was restored on the platform console`);
}
});
test('a Nearle staff session is still restored', () => {
// The refusal must not be so broad that it locks out the people this console
// is for — which is the failure that would present as "nobody can sign in".
store.set(
SESSION_STORAGE_KEY,
JSON.stringify({ userid: 1, role: 'nearle-admin', token: 'w1.a.b' }),
);
assert.equal(restore()?.role, 'nearle-admin');
});

298
src/auth/session.ts Normal file
View File

@@ -0,0 +1,298 @@
/**
* Sign-in and session persistence.
*
* 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';
/**
* 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. */
export class PasswordSetupRequiredError extends Error {
readonly userid: number;
constructor(userid: number) {
super('This account needs a password before it can sign in.');
this.name = 'PasswordSetupRequiredError';
this.userid = userid;
}
}
interface LoginBody {
authname: string;
password: string;
/**
* Not optional in practice.
*
* The lookup behind every login is `WHERE authname = ? AND configid = ?`
* (`userRepository.go:218`). Omitted, Go receives 0, the query matches no
* row, and every account on the platform answers "Invalid Email". The console
* surface is configid 1 — `createtenantuser` hard-codes it into the account it
* spawns for exactly this reason.
*/
configid: number;
}
/** The console's surface. Every account this app can sign in carries it. */
const CONFIG_ID = 1;
/**
* Signs in.
*
* `applogin`, not `tenant/weblogin`. The latter carries a check the former does
* not — `request.roleid == app_users.roleid` (`userService.go:224`) — and since
* Go's zero value is 0, a request without a roleid means "roleid must be 0".
* That is the branch-user role, so weblogin silently locked out every Store
* Admin and every platform operator with a 403 reading "Unauthorized email".
*
* The handler answers HTTP 200 with `status: false` for most failures, so the
* envelope is inspected rather than the HTTP status.
*/
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 },
);
// 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.
if (envelope.code === 409 && envelope.details?.setup === true) {
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
}
if (envelope.status !== true || !envelope.details) {
throw new Error(loginMessage(envelope.code, envelope.message));
}
// 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;
}
/**
* What the next step is for this email — before anyone types a password.
*
* `'setup'` means the account exists and has never had a password; `'password'`
* means it has one. Anything else throws with a message worth showing.
*
* ── Why probe at all ─────────────────────────────────────────────────────────
*
* A tenant created by `createtenantuser`, and every branch created by
* `createtenantlocation`, is spawned with an EMPTY password. Their owner's
* first sign-in therefore cannot succeed, and asking them for a password first
* asks for something that does not exist — they type a guess, watch it fail,
* and only then are told to invent one. The old console avoids that by checking
* the email before the password field is ever shown, and it is right to.
*
* ── How one endpoint answers two questions ───────────────────────────────────
*
* There is no lookup endpoint. This posts to `applogin` with no password at
* all, which `userService.go:64-123` answers in four distinguishable ways:
*
* 409 + status false → no such account ("Invalid Email")
* 403 → account deactivated
* 409 + status true → exists, no password set (carries the userid)
* 401 + status true → exists, has a password ("Password is required")
*
* The last one is the whole trick: a password-less attempt against a real
* account is refused with a DIFFERENT code than a wrong password, so existence
* can be established without guessing at one.
*
* This does tell an anonymous caller whether an email has an account here. That
* is a real disclosure and worth naming — but the login already answers
* "Invalid Email" versus "Incorrect password" to any caller who sends a wrong
* 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 };
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 } },
);
if (envelope.code === 409 && envelope.details?.setup === true) {
return { state: 'setup', userid: envelope.details.userid ?? 0 };
}
// "Password is required" — the account is real and has one. Exactly what we
// wanted to learn, arriving as a refusal.
if (envelope.code === 401) {
return { state: 'password' };
}
throw new Error(loginMessage(envelope.code, envelope.message));
}
/** The backend's floor, enforced here too so the refusal is instant. */
export const MIN_PASSWORD_LENGTH = 6;
/**
* Sets the password on an account that has never had one.
*
* `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.
*
* 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.
*
* 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.
*/
export async function setInitialPassword(userid: number, password: string): Promise<void> {
if (password.length < MIN_PASSWORD_LENGTH) {
throw new Error(`Use at least ${MIN_PASSWORD_LENGTH} characters.`);
}
await api.post<unknown>(`${WEB}/users/setpassword`, { userid, password });
}
/**
* The backend's own words, where they are usable, and ours where they are not.
*
* "Invalid Email" is technically true and unhelpful — the same answer covers a
* typo and a till account, because roleids 7 and 8 are excluded from every web
* login lookup. A cashier is not refused here, they are not found, so the copy
* must not say "wrong password".
*/
function loginMessage(code: number | undefined, message: string | undefined): string {
if (code === 409) {
return 'We do not recognise that email. Till accounts (supervisor or cashier) sign in at the terminal, not here.';
}
if (code === 401) {
return message?.toLowerCase().includes('required')
? 'Enter your password.'
: 'That password is not right.';
}
// 403 covers both an inactive account and an inactive store, and the two
// messages differ — pass the backend's through rather than flattening them.
if (code === 403) return message ?? 'This account cannot sign in. Contact your administrator.';
return message ?? 'Sign-in failed';
}
export function persist(session: SessionUser): void {
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
}
export function restore(): SessionUser | null {
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;
}
}
export function clear(): void {
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,59 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { IS_PLATFORM, isAllowedHere, WORKSPACE, wrongConsoleMessage } from './workspace';
import type { ConsoleRole } from './roles';
/*
Who may sign in to the platform console.
Nearle's own staff sign in here; merchants and their branch users sign in at the
merchant console, which is a separate application with a separate deploy. An
account belonging there is turned away rather than redirected — no session
crosses a domain boundary 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 merchant 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'];
test('this build is the platform console, with nothing to configure', () => {
// A constant rather than a build flag. The merchant console reads one because
// it once served both sites; here a variable would only create a way to
// deploy this application as something it is not.
assert.equal(WORKSPACE, 'platform');
assert.equal(IS_PLATFORM, true);
});
test('only Nearle staff are admitted', () => {
assert.equal(isAllowedHere('nearle-admin'), true);
assert.equal(isAllowedHere('store-admin'), false);
assert.equal(isAllowedHere('store-manager'), 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 platform console.
const admitted = ROLES.filter(isAllowedHere);
assert.deepEqual(admitted, ['nearle-admin']);
});
test('the refusal names the merchant console rather than blaming the account', () => {
const message = wrongConsoleMessage('store-admin');
assert.match(message, /app\.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('each refused role is named in words a person uses', () => {
assert.match(wrongConsoleMessage('store-admin'), /Store admin/);
assert.match(wrongConsoleMessage('store-manager'), /Store user/);
});

69
src/auth/workspace.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { ConsoleRole } from './roles';
/**
* Who may sign in to the Nearle platform console.
*
* ── Why this is a constant here ─────────────────────────────────────────────
*
* The merchant console reads a `VITE_WORKSPACE` build flag, because for a while
* one codebase served both sites and had to be told which it was. This
* repository IS the platform console — there is nothing to decide and no flag
* to get wrong. A variable would only create a way to deploy this application
* as something it is not.
*
* ── The separation is a refusal, not a redirect ─────────────────────────────
*
* Nearle's own staff sign in here; merchants and their branch users sign in at
* the merchant console. An account belonging there is turned away with a
* sentence naming where it belongs — it is not bounced across a domain
* boundary carrying a half-made session.
*
* The role is not known until the password has been checked, 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, which
* is what keeps a merchant from ending up signed in here with no routes to
* reach.
*/
export const WORKSPACE = 'platform' as const;
export const IS_PLATFORM = true;
const ALLOWED: ReadonlySet<ConsoleRole> = new Set<ConsoleRole>(['nearle-admin']);
export function isAllowedHere(role: ConsoleRole): boolean {
return ALLOWED.has(role);
}
/**
* The merchant console's address, for the sentence shown to somebody in the
* wrong place.
*
* A build variable rather than a constant, because the two sites 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 MERCHANT_HOST =
(import.meta.env?.['VITE_MERCHANT_HOST'] ?? '').trim() || 'app.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.
*/
export function wrongConsoleMessage(role: ConsoleRole): string {
return `This is the Nearle platform console. ${roleWord(role)} accounts sign in at ${MERCHANT_HOST}.`;
}
function roleWord(role: ConsoleRole): string {
switch (role) {
case 'nearle-admin':
// Unreachable: this role is the one allowed here, so it never 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.
return 'Nearle staff';
case 'store-admin':
return 'Store admin';
case 'store-manager':
return 'Store user';
}
}

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from 'react';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner';
import { HStack } from '@astryxdesign/core/HStack';
import { AlertTriangle, Inbox } from 'lucide-react';
import { errorMessage } from '@/api/client';
export interface DataStateProps {
isLoading: boolean;
error: unknown;
isEmpty: boolean;
/** What is missing, in the user's words — "No tenants yet". */
emptyTitle: string;
emptyDescription?: string;
emptyAction?: ReactNode;
children: ReactNode;
}
/**
* The four states every list has, in one place.
*
* Written as a wrapper rather than repeated per screen because the failure mode
* it prevents is a real one: pages that handle loading and success, and render
* a blank rectangle for empty and error.
*/
export function DataState({
isLoading,
error,
isEmpty,
emptyTitle,
emptyDescription,
emptyAction,
children,
}: DataStateProps) {
if (isLoading) {
return (
<HStack justify="center" padding={6}>
<Spinner size="md" label="Loading" />
</HStack>
);
}
if (error) {
return (
<EmptyState
title="That did not load"
description={errorMessage(error)}
icon={<AlertTriangle size={22} />}
/>
);
}
if (isEmpty) {
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={<Inbox size={22} />}
actions={emptyAction}
/>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,139 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { isStaleChunkError } from '@/lib/staleChunk';
/**
* The last line before a white page.
*
* React unmounts the entire tree when a render throws and nothing catches it.
* With no boundary anywhere in the app, one bad field shape on one card takes
* the whole console down to an empty document — no message, no route, nothing
* to act on but the browser console. That is exactly how a POS-health response
* typed as an array but delivered as an object presented itself: a blank screen
* on sign-in, with the cause four layers down.
*
* So this is not decoration. It converts "the app is broken" into "this screen
* is broken, and here is what it said", which is the difference between a bug
* report and a guess.
*
* Deliberately a class: `getDerivedStateFromError` has no hook equivalent.
*/
interface Props {
children: ReactNode;
/** Names the area in the message — "the Console page", "the assistant". */
area?: string;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
// Kept as console output rather than sent anywhere: there is no error
// reporting endpoint, and inventing one would be a network call nobody
// asked for. The component stack is the part that is not in the throw.
console.error('[nearle] render failed', error, info.componentStack);
}
private reset = () => {
this.setState({ error: null });
};
private reload = () => {
window.location.reload();
};
override render() {
const { error } = this.state;
if (!error) return this.props.children;
const area = this.props.area ?? 'this screen';
/**
* A page whose code never arrived cannot be re-rendered into existence.
*
* "Try again" clears the error and renders the same `lazy()` component,
* which requests the same missing file and throws the same error — the
* button looked like a recovery and was a loop. `lib/staleChunk.ts` already
* reloads once on its own; landing here means that reload has happened and
* not helped, or was suppressed to avoid a boot loop, so the honest offer
* is a reload the person chooses and a message that names the cause.
*/
const isStale = isStaleChunkError(error);
return (
<div
role="alert"
style={{
margin: '48px auto',
maxWidth: 620,
padding: '28px 32px',
borderRadius: 'var(--card-radius)',
border: '1px solid #F1D3D3',
background: '#FFFBFB',
fontFamily: 'inherit',
}}
>
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
{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
? import.meta.env.DEV
? 'The dev server restarted or re-optimised its dependencies, so the module URL this tab was holding no longer resolves. Nothing is wrong with your code — reloading picks up the current module graph.'
: 'A new version of the console was deployed, so the file this tab was about to load no longer exists. Nothing is wrong with your data — reloading picks up the current version.'
: 'The rest of the console is fine — this is one screen, not the whole app. The message below is what broke, and the full stack is in the browser console.'}
</p>
<pre
style={{
margin: '0 0 20px',
padding: '12px 14px',
borderRadius: 10,
background: '#FFF1F1',
color: '#7A2020',
fontSize: 13,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{error.message || String(error)}
</pre>
<button
type="button"
onClick={isStale ? this.reload : this.reset}
style={{
padding: '9px 18px',
/* 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,
fontWeight: 500,
cursor: 'pointer',
}}
>
{isStale ? 'Reload the page' : 'Try again'}
</button>
</div>
);
}
}

View File

@@ -0,0 +1,53 @@
import { Text } from '@astryxdesign/core/Text';
import { HStack } from '@astryxdesign/core/HStack';
import { RefreshCw } from 'lucide-react';
export interface FreshnessProps {
/** `dataUpdatedAt` from the query that fed the figure. */
updatedAt: number | undefined;
isFetching?: boolean;
/** Bills sitting on a till that has not synced. Hidden when zero. */
unsynced?: number;
}
function clock(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* How old the number above this line is.
*
* Online orders land as they are placed; counter sales arrive on the console's
* 30-second refetch, and whatever is still sitting on an offline till has not
* arrived at all. So any figure blending the two is eventually consistent, and
* printing one confident number without saying when it was true is how a screen
* loses the room's trust.
*/
export function Freshness({ updatedAt, isFetching, unsynced }: FreshnessProps) {
return (
<HStack align="center" gap={1.5} wrap="wrap">
<HStack align="center" gap={0.5}>
<RefreshCw
size={12}
style={{
color: 'var(--color-slate-400)',
animation: isFetching ? 'spin 1s linear infinite' : undefined,
}}
/>
<Text type="body" size="xsm" color="secondary">
{updatedAt ? `as of ${clock(updatedAt)}` : 'not loaded yet'}
</Text>
</HStack>
{typeof unsynced === 'number' && unsynced > 0 ? (
<Text type="body" size="xsm" style={{ color: 'var(--color-warning, #b7860b)' }}>
· {unsynced} bill{unsynced === 1 ? '' : 's'} not yet synced
</Text>
) : null}
</HStack>
);
}

View File

@@ -0,0 +1,89 @@
import type { ReactNode } from 'react';
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';
export interface KpiCardProps {
/** Small-caps label. Say what it is, not what it means. */
label: string;
value: string;
/**
* 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 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;
}
export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardProps) {
return (
<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.
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>
<span className="kpi-card-value">{value}</span>
{note ? <span className="kpi-card-note">{note}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { VStack } from '@astryxdesign/core/VStack';
/**
* Two page measures, and the rule for choosing between them.
*
* KROW declares `--container-max: 80rem` for a page and a wider shell measure
* for the app frame, and the distinction matters: a form stretched across
* 1600px puts five inputs in a row and makes the eye travel further than the
* reading task deserves, while a table at 80rem wastes half the screen.
*
* 'reading' — forms, single flows, anything read top to bottom
* 'data' — tables, dashboards, catalogue grids
*/
export type PageMeasure = 'reading' | 'data';
const MAX_WIDTH: Record<PageMeasure, string> = {
reading: 'var(--container-page, 80rem)',
data: 'var(--container-admin, 102rem)',
};
export function PageBody({
measure = 'data',
children,
}: {
measure?: PageMeasure;
children: ReactNode;
}) {
return (
<VStack gap={3} width="100%" style={{ maxWidth: MAX_WIDTH[measure] }}>
{children}
</VStack>
);
}

View File

@@ -0,0 +1,107 @@
import type { ReactNode } from 'react';
import { StickyRow } from './StickyRow';
export interface PageHeaderProps {
title: string;
/** Right-aligned actions, wrapping. */
actions?: ReactNode;
/** An optional tabs row directly under the header rule. */
tabs?: ReactNode;
/**
* Put the tabs on the SAME line as the actions — tabs left, actions right —
* instead of on a row of their own beneath.
*
* Worth having now that the page titles are gone: with nothing above them the
* tabs and a lone button sat on two nearly empty lines, and pulling them onto
* one gives the page back a row without crowding anything.
*/
isTabsInline?: boolean;
}
/**
* The page frame header, built to KROW's `AdminPage` spec.
*
* Actions right-aligned and wrapping, with an optional tabs row beneath.
* Everything the page stacks below sits on a 24px rhythm.
*
* The visible title, count, Live pill and description are gone from every page
* in all three workspaces. They restated what the chrome already says — the top
* bar names the section, the branch picker gives the count — and the
* description was a line of prose above data that nobody reads twice. The title
* survives as a screen-reader-only h1; see the note at the call.
*
* There was a hairline rule under all of this, with 16px of padding above it
* and another 12px below before the tabs — 28px of nothing plus a line, on
* every page of all three consoles. The line was doing no work the whitespace
* was not already doing: a page title set in the display face at that size is
* separated from what follows by being a title. Removing it and closing the gap
* gives every page back roughly 45px above the fold, which on a catalogue is
* a row of products.
*
* 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 (
<>
{/*
The title is still here, and still read aloud — it is just not drawn.
Removing it visually was the ask; removing it outright would leave every
page in the console with no h1, no document outline and nothing for a
screen reader to announce on navigation. `sr-only` is absolutely
positioned, so it is out of flow and costs no layout.
*/}
<h1 className="sr-only">{title}</h1>
{/*
No row at all when there is nothing to put in it.
It used to render an empty <header> regardless. At zero height that
looks free, but it is still a flex child, so it collected the column's
12px gap and pushed everything down — a page with no actions sat 36px
below the nav bar while Inventory sat at 24. The gap now comes from one
place, the column's own padding, and every page matches.
*/}
{hasRow || hasTabsRow ? (
<StickyRow>
{hasRow ? (
<header
style={{
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>
) : null}
</header>
) : 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,39 @@
import type { ReactNode } from 'react';
import { Heading } from '@astryxdesign/core/Heading';
import { Text } from '@astryxdesign/core/Text';
import { HStack } from '@astryxdesign/core/HStack';
export interface SectionHeaderProps {
title: string;
/**
* Sits inline beside the title, not beneath it — and only when it carries a
* fact, such as a count.
*
* It used to be required, on the theory that every section should say what it
* is showing. In practice that produced a line of editorial beside every
* heading — "how today is going", "a queue nobody can see from the shop
* floor" — which reads as noise once you have seen the page twice. The
* heading and the data under it say enough.
*/
note?: string;
/** A control or a "View all" link, right-aligned. */
action?: ReactNode;
}
export function SectionHeader({ title, note, action }: SectionHeaderProps) {
return (
<HStack justify="between" align="center" gap={2} wrap="wrap">
<HStack align="center" gap={1.5} wrap="wrap">
<Heading level={2}>
{title}
</Heading>
{note ? (
<Text type="body" color="secondary">
{note}
</Text>
) : null}
</HStack>
{action}
</HStack>
);
}

View File

@@ -0,0 +1,235 @@
import { useRef, useState, type DragEvent } from 'react';
import { FileSpreadsheet, Trash2, Upload } from 'lucide-react';
/**
* The workbook dropzone.
*
* Astryx's `FileInput` in dropzone mode is a white slab with a dashed rule and
* a bare arrow — correct, and completely mute about what it wants or whether it
* is even available. This one says all three things: what it takes, whether it
* can take it right now, and what it is holding.
*
* Three states, and each looks different at a glance rather than on reading:
*
* - **Waiting** — a tinted panel with a brand-lit icon and an explicit
* "Choose file" affordance, because a dropzone that only accepts a drag is
* unusable to anyone on a laptop with the file already in a dialog.
* - **Dragging** — the brand colour comes up and the panel lifts. A dropzone
* that does not visibly react is one people drop next to.
* - **Filled** — the file itself, with its size and a way to swap it. The old
* control kept saying "Drop an .xlsx here" after you already had.
*
* It stays a real `<input type="file">` under the surface, so the keyboard, the
* screen reader and the OS file dialog all behave as they should — the div is
* decoration over an input, not a replacement for one.
*/
export interface SheetDropzoneProps {
file: File | null;
onFile: (file: File | null) => void;
/** Comma-separated extensions, passed straight to the input. */
accept?: string;
/** Why the control is unavailable. Present means disabled. */
blockedReason?: string;
}
const ACCEPTED = ['.xlsx', '.xls', '.csv'];
export function SheetDropzone({
file,
onFile,
accept = ACCEPTED.join(','),
blockedReason,
}: SheetDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const isDisabled = Boolean(blockedReason);
function open() {
if (!isDisabled) inputRef.current?.click();
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setIsDragging(false);
if (isDisabled) return;
const dropped = event.dataTransfer.files?.[0];
if (dropped) onFile(dropped);
}
/* ── Filled ───────────────────────────────────────────────────────────── */
if (file) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '14px 16px',
borderRadius: 'var(--card-radius)',
border: 'var(--card-border)',
background: 'var(--color-surface-subtle)',
}}
>
<span
style={{
display: 'grid',
placeItems: 'center',
width: 40,
height: 40,
flex: 'none',
borderRadius: 11,
background: 'color-mix(in oklab, var(--color-brand) 12%, transparent)',
color: 'var(--color-brand)',
}}
>
<FileSpreadsheet size={19} />
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-ink-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{file.name}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
{formatSize(file.size)}
</div>
</div>
<button type="button" onClick={open} style={ghostButtonStyle}>
Replace
</button>
<button
type="button"
onClick={() => onFile(null)}
aria-label="Remove file"
style={{ ...ghostButtonStyle, padding: '7px 9px' }}
>
<Trash2 size={14} />
</button>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
/>
</div>
);
}
/* ── Waiting / dragging ───────────────────────────────────────────────── */
return (
<div
onClick={open}
onDragOver={(event) => {
event.preventDefault();
if (!isDisabled) setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
role="button"
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
}}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: '16px 20px',
borderRadius: 'var(--card-radius)',
border: `1.5px dashed ${
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
}`,
background: isDragging
? 'color-mix(in oklab, var(--color-brand) 8%, transparent)'
: 'color-mix(in oklab, var(--color-brand) 3%, transparent)',
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.55 : 1,
textAlign: 'center',
outline: 'none',
transition: 'background .16s ease, border-color .16s ease, transform .16s ease',
transform: isDragging ? 'scale(1.004)' : 'none',
}}
>
<span
style={{
display: 'grid',
placeItems: 'center',
width: 34,
height: 34,
borderRadius: 10,
background: isDragging
? 'var(--color-brand)'
: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
color: isDragging ? '#fff' : 'var(--color-brand)',
transition: 'background .16s ease, color .16s ease',
}}
>
<Upload size={16} />
</span>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{isDisabled
? blockedReason
: isDragging
? 'Drop it here'
: 'Drop your workbook, or click to browse'}
</div>
<div style={{ fontSize: 11.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
{ACCEPTED.join(' · ')}
</div>
</div>
<input
ref={inputRef}
type="file"
accept={accept}
disabled={isDisabled}
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
/>
</div>
);
}
const ghostButtonStyle: React.CSSProperties = {
flex: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '7px 12px',
borderRadius: 9,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-2)',
fontSize: 12.5,
fontWeight: 600,
fontFamily: 'inherit',
cursor: 'pointer',
};
/** kB up to a megabyte, then MB. Nobody needs "1420800 bytes". */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${Math.round(kb)} kB`;
return `${(kb / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
/**
* A row of page controls held under the nav bar while the page scrolls.
*
* Whatever sits above a page — its view tabs, its search, its action buttons —
* used to leave with the scroll. Reaching the bottom of a long list put every
* control over that list off the top of the window, and the only way back to
* them was to scroll back through everything just read. The further in somebody
* was, the further from the controls.
*
* The page still scrolls normally. Nothing here gets a scrollbar of its own —
* the row simply stays where it is. Positioning and the canvas it paints live
* in `.page-sticky`; this component exists for the one part CSS cannot answer,
* which is whether the row is currently pinned.
*
* `PageHeader` wraps itself in this, so every page built from it gets the
* behaviour without asking. Dispatch builds its own switcher rather than going
* through `PageHeader`, so it wraps itself — which is the reason this is a
* component and not four more lines inside the header.
*/
export function StickyRow({ children }: { children: ReactNode }) {
/**
* Pinned or not, so the hairline can be drawn only when it is.
*
* At rest the nav bar's own hairline sits just above this one, and two
* parallel lines across the page with nothing between them read as a mistake.
* The line is what separates the row from content passing UNDER it, so it
* belongs to the pinned state rather than to the row.
*
* Watched with an observer rather than a scroll handler: a scroll listener
* runs on every frame of every scroll to answer a question that changes
* twice. The negative top margin puts the observer's boundary a pixel above
* where the row comes to rest, so "no longer fully inside that box" means
* exactly "stuck".
*/
const rowRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
const element = rowRef.current;
/* Guarded for any renderer without it — the row still sticks, it just
never grows its line, which is a missing hairline rather than a crash. */
if (!element || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)),
{ rootMargin: '-57px 0px 0px 0px', threshold: 1 },
);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div ref={rowRef} className="page-sticky" data-stuck={isStuck ? 'true' : 'false'}>
{children}
</div>
);
}

100
src/components/TabBar.tsx Normal file
View File

@@ -0,0 +1,100 @@
import type { ReactNode } from 'react';
import './tabBar.css';
/**
* The console's view switcher — Orders / Deliveries / Counter sales, Catalogue
* / My stock / My requests, Store users / Terminal accounts / Riders.
*
* ── There were six of these ─────────────────────────────────────────────────
*
* `TabButton` was defined privately in `StoresPage`, `InventoryPage`,
* `ReportsPage`, `SalesPage`, `UsersPage` and `store-user/ui.tsx` — six copies
* of the same button, each styled inline, and they had already drifted: three
* of them filled the count badge with `--color-warning`, so "85" under Orders
* was drawn in the colour this console reserves for something being wrong. A
* count is not an alarm. Sales had the sensible version. Nobody could see the
* difference because no two of them are ever on screen together.
*
* ── Why it is a track and not loose pills ───────────────────────────────────
*
* The old buttons were transparent until selected, so an unselected set read as
* four pieces of text floating above the page with no indication they were one
* control or that picking one deselects the others. They sit in a recessed
* track now, with the selected tab raised out of it on the card surface — the
* standard segmented control, and the one shape that says "these are the views,
* you are in this one" without needing a label to say so.
*/
export interface TabBarProps {
children: ReactNode;
/** `sm` for a secondary strip — a filter under a primary switcher. */
size?: 'md' | 'sm';
'aria-label'?: string;
}
export function TabBar({ children, size = 'md', ...rest }: TabBarProps) {
return (
<div className="tabbar" data-size={size} role="tablist" aria-label={rest['aria-label']}>
{children}
</div>
);
}
export interface TabProps {
label: string;
icon?: ReactNode;
isActive: boolean;
onClick: () => void;
/**
* The count beside the label. Omit it and no count is drawn.
*
* Whether ZERO is worth drawing depends on what the tab is, so the caller
* decides rather than this component:
*
* - A VIEW passes `x.length || undefined`. "Deliveries 0" beside "Orders
* 85" reads as a broken tab; "Deliveries" alone says nothing and lets the
* empty state explain.
* - A STATUS in a ladder passes the raw count. "Cancelled 0" is the answer
* to a question the reader is asking — nothing was cancelled — and a rung
* that loses its number when it empties makes the ladder jump about as
* the day goes on.
*/
count?: number | undefined;
/**
* Draws a hairline before this tab, dividing the track into groups.
*
* Sales needs it: Orders and Deliveries are two halves of the app's own
* trade, and Counter sales is a different channel entirely. The rule says so
* without spending a tab on a heading.
*/
isGroupStart?: boolean;
/**
* A pulsing dot after the label, for a view that updates by itself.
*
* Dispatch's Active Fleet is the one: it reads rider positions live, and the
* dot is how the board says so. A named prop rather than a general-purpose
* slot — a `trailing` that took any node would be the seam this component
* exists to close, and there is exactly one live view in the console.
*/
isLive?: boolean;
}
export function Tab({ label, icon, isActive, onClick, count, isGroupStart, isLive }: TabProps) {
return (
<button
type="button"
role="tab"
className="tabbar-tab"
aria-selected={isActive}
{...(isGroupStart ? { 'data-group-start': 'true' } : {})}
onClick={onClick}
>
{icon ? <span className="tabbar-icon">{icon}</span> : null}
<span className="tabbar-label">{label}</span>
{count === undefined ? null : <span className="tabbar-count">{count}</span>}
{/* Labelled, not decorative: a green dot with no accessible name is a
colour that means something to sighted readers only. */}
{isLive ? <span className="tabbar-live" role="img" aria-label="updating live" /> : null}
</button>
);
}

View File

@@ -0,0 +1,49 @@
import { Pagination } from '@astryxdesign/core/Pagination';
import type { Paged } from './usePaged';
import { PAGE_SIZES } from './usePaged';
import './tablePager.css';
/**
* The pager that sits under a table.
*
* One component so every table in the console counts, labels and behaves the
* same way, rather than each page inventing its own row of buttons.
*
* ── It hides itself ─────────────────────────────────────────────────────────
*
* Nothing renders while everything fits on one page. That is what makes it safe
* to put under EVERY table, including the ones that usually hold four rows: a
* pager reading "1–4 of 4" next to a dead prev/next pair is noise, and noise
* under every table is worse than no pager at all. It appears exactly when it
* has something to offer.
*
* `variant="count"` — "21–40 of 96" — rather than a strip of page numbers.
* With 25 rows a page a busy day is four pages, and the number an operator
* actually wants is how much is left, not which of four buttons is lit.
*/
export function TablePager({
paged,
label = 'rows',
}: {
paged: Paged<unknown>;
/** What is being counted, for the screen-reader label: "orders", "products". */
label?: string;
}) {
if (paged.totalPages <= 1) return null;
return (
<div className="table-pager">
<Pagination
page={paged.page}
onChange={paged.setPage}
totalItems={paged.total}
pageSize={paged.pageSize}
pageSizeOptions={PAGE_SIZES}
onPageSizeChange={paged.setPageSize}
variant="count"
size="sm"
label={`Page through ${label}`}
/>
</div>
);
}

436
src/components/TrailMap.tsx Normal file
View File

@@ -0,0 +1,436 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import './trailMap.css';
/**
* A map, for things that have a position.
*
* ── Why leaflet directly and not react-leaflet ──────────────────────────────
*
* A leaflet map is an imperative object that owns a DOM node and must be torn
* down by hand — `remove()`, or the tile layer keeps fetching and the container
* keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in
* components and adds a second package that has to track React's major version
* forever. The wrapping is about forty lines; it is written here instead.
*
* ── Why the pins are divIcons ───────────────────────────────────────────────
*
* Leaflet's default marker is a PNG resolved relative to the stylesheet, which
* every bundler rewrites and breaks — the classic "markers are invisible"
* bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is
* markup, so it ships with the bundle, takes the console's brand colour and
* needs no image at all.
*/
export interface MapTrail {
id: string | number;
label: string;
points: readonly { lat: number; lng: number }[];
colour: string;
/**
* Drawn thinner and paler, so a focused round reads against the others.
*
* Muted rather than hidden: a rider's route is still context for the rider
* beside them, and hiding it would make two overlapping rounds impossible to
* compare — which is the reason to focus one in the first place.
*/
isMuted?: boolean;
}
export interface MapPinPopup {
title?: string;
orderId?: string;
customer?: string;
address?: string;
status?: string;
statusColor?: string;
time?: string;
rider?: string;
amount?: string;
step?: number;
subtitle?: string;
items?: { orderid: string; status: string }[];
}
export interface MapPin {
id: string | number;
lat: number;
lng: number;
label: string;
/** Shown under the label in the popup. Plain text, one line per entry. */
lines?: string[];
popup?: MapPinPopup;
popupHtml?: string;
colour?: string;
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */
isFaded?: boolean;
/**
* A square marks a place rather than an event — a shop, a depot. Round pins
* are the things that happened there, so a branch never reads as one more
* customer among its own drops.
*/
shape?: 'round' | 'square';
/**
* The stop's place in the round, drawn inside the pin.
*
* Without it a route is a line through identical dots and there is no way to
* read which end it started from — the map says "this is the order it was
* worked" and then withholds the order. The console this replaces numbers
* every drop for the same reason.
*/
step?: number;
/** Ringed as the round's first stop. */
isStart?: boolean;
/**
* Dim this pin. Used when another round is focused, so the one being read
* stands out without the rest disappearing — a rider's stops are still
* context for the rider beside them.
*/
isMuted?: boolean;
}
/** Tamil Nadu, so an empty map still shows the right part of the world. */
const FALLBACK: L.LatLngExpression = [11.0168, 76.9558];
export function TrailMap({
trails = [],
pins = [],
height = 380,
emptyNote = 'Nothing to place on the map yet.',
}: {
trails?: readonly MapTrail[];
pins?: readonly MapPin[];
height?: number;
emptyNote?: string;
}) {
const host = useRef<HTMLDivElement | null>(null);
const map = useRef<L.Map | null>(null);
/* Everything drawn, kept together so a redraw clears exactly what it drew.
Clearing the map wholesale would take the tile layer with it. */
const drawn = useRef<L.LayerGroup | null>(null);
const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0;
useEffect(() => {
if (!host.current || map.current) return;
const instance = L.map(host.current, {
center: FALLBACK,
zoom: 12,
// The console scrolls; a wheel over the map should scroll the page, not
// zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people
// expect from a map embedded in a document.
scrollWheelZoom: false,
attributionControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors',
}).addTo(instance);
drawn.current = L.layerGroup().addTo(instance);
map.current = instance;
return () => {
instance.remove();
map.current = null;
drawn.current = null;
};
}, []);
useEffect(() => {
const instance = map.current;
const layer = drawn.current;
if (!instance || !layer) return;
layer.clearLayers();
const bounds = L.latLngBounds([]);
for (const trail of trails) {
if (trail.points.length < 2) continue;
const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]);
/* A white casing under the coloured line.
Map tiles are busy and mid-toned, and a 3px stroke of any colour
disappears into a main road drawn underneath it. The casing gives the
line its own edge so it reads as something laid ON the map rather than
part of it. Borrowed from the console this replaces, which draws the
same pair — it is the one thing that made its routes legible. */
L.polyline(line, {
color: '#ffffff',
weight: 7,
// Muted with its line, or a dimmed route shows a bright white casing
// and ends up louder than the one being focused.
opacity: trail.isMuted ? 0.2 : 0.65,
lineJoin: 'round',
lineCap: 'round',
interactive: false,
}).addTo(layer);
L.polyline(line, {
color: trail.colour,
weight: trail.isMuted ? 2.5 : 3.5,
opacity: trail.isMuted ? 0.35 : 0.95,
// Rounded joins, or a dense trail draws spikes at every turn.
lineJoin: 'round',
lineCap: 'round',
})
.bindTooltip(trail.label, { sticky: true })
.addTo(layer);
line.forEach((point) => bounds.extend(point));
}
for (const pin of pins) {
const colour = pin.colour ?? 'var(--color-brand)';
L.marker([pin.lat, pin.lng], {
title: pin.label,
icon: L.divIcon({
className: 'trail-pin-wrap',
// A numbered pin has to be big enough to hold two digits legibly, so
// the size follows the content rather than being fixed.
html:
`<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}"` +
` data-shape="${pin.shape ?? 'round'}"` +
` data-muted="${pin.isMuted ? 'true' : 'false'}"` +
` data-numbered="${pin.step ? 'true' : 'false'}"` +
` data-start="${pin.isStart ? 'true' : 'false'}"` +
` style="--pin:${escapeAttr(colour)}">` +
`${pin.step ? escapeHtml(String(pin.step)) : ''}</span>`,
iconSize: pin.step ? [22, 22] : [16, 16],
iconAnchor: pin.step ? [11, 11] : [8, 8],
}),
})
.bindPopup(pin.popupHtml ?? formatPinPopup(pin), {
className: 'trail-leaflet-popup-card',
closeButton: true,
maxWidth: 290,
minWidth: 230,
autoPanPadding: [20, 20],
})
.addTo(layer);
bounds.extend([pin.lat, pin.lng]);
}
if (bounds.isValid()) {
// `maxZoom` matters: a single pin, or a rider who never left one street,
// otherwise zooms to building level and the map shows one grey rectangle
// with no landmarks to orient by.
instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 });
}
}, [trails, pins]);
/* Leaflet measures its container once, at construction. Inside a drawer or a
tab the container is often zero-height at that moment, and the map renders
as a grey strip with one tile in the corner until something resizes the
window. Re-measuring whenever the box changes size fixes it for good,
including when the drawer animates open. */
useEffect(() => {
const node = host.current;
const instance = map.current;
if (!node || !instance || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => instance.invalidateSize());
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div className="trail-map" style={{ height }}>
<div ref={host} className="trail-map-canvas" />
{isEmpty ? <div className="trail-map-empty">{emptyNote}</div> : null}
</div>
);
}
/**
* Distinct, legible line colours.
*
* Hand-picked rather than generated from a hue wheel: evenly spaced hues put
* two yellows next to each other on an OSM tile and both vanish. These are all
* dark enough to read over map detail and different enough to tell apart at the
* width of a polyline.
*/
export const TRAIL_COLOURS = [
'#662582',
'#0f8a5f',
'#c2410c',
'#1d4ed8',
'#b91c1c',
'#0e7490',
'#7c2d12',
'#4d7c0f',
] as const;
export function trailColour(index: number): string {
return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string;
}
function formatPinPopup(pin: MapPin): string {
const p = pin.popup;
if (p) {
if (p.items && p.items.length > 1) {
return `
<div class="map-popup-card">
<div class="map-popup-header">
<div class="map-popup-tag">
<span class="map-popup-badge-icon">📦</span>
<span class="map-popup-id">${p.items.length} Orders Here</span>
</div>
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
</div>
<div class="map-popup-body">
<div class="map-popup-customer-row">
<div class="map-popup-avatar">🏢</div>
<div class="map-popup-cust-details">
<span class="map-popup-customer">${escapeHtml(p.customer || 'Multiple Drops')}</span>
<span class="map-popup-address">${escapeHtml(p.address || '')}</span>
</div>
</div>
<div class="map-popup-multi-list">
${p.items.slice(0, 4).map(item => `
<div class="map-popup-item-row">
<span class="map-popup-item-id">#${escapeHtml(item.orderid)}</span>
<span class="map-popup-item-status">${escapeHtml(item.status)}</span>
</div>
`).join('')}
${p.items.length > 4 ? `<div class="map-popup-item-more">+${p.items.length - 4} more orders</div>` : ''}
</div>
</div>
<div class="map-popup-footer">
<div class="map-popup-rider">
<span class="map-popup-rider-icon">🛵</span>
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Assigned')}</span>
</div>
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
</div>
</div>
`;
}
if (p.orderId) {
return `
<div class="map-popup-card">
<div class="map-popup-header">
<div class="map-popup-tag">
<span class="map-popup-badge-icon">📦</span>
<span class="map-popup-id">#${escapeHtml(p.orderId)}</span>
</div>
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
</div>
<div class="map-popup-body">
<div class="map-popup-customer-row">
<div class="map-popup-avatar">${escapeHtml((p.customer || 'C').charAt(0).toUpperCase())}</div>
<div class="map-popup-cust-details">
<span class="map-popup-customer">${escapeHtml(p.customer || 'Customer')}</span>
<span class="map-popup-address">${escapeHtml(p.address || 'Delivery Address')}</span>
</div>
</div>
<div class="map-popup-status-bar">
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
<span class="map-popup-status-dot"></span>
<span class="map-popup-status-text">${escapeHtml(p.status || 'Pending')}</span>
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
</span>
</div>
</div>
<div class="map-popup-footer">
<div class="map-popup-rider">
<span class="map-popup-rider-icon">🛵</span>
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Rider')}</span>
</div>
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
</div>
</div>
`;
}
const isStoreHub = p.subtitle?.includes('Hub') || p.title?.includes('Hub') || p.title?.includes('R mart') || pin.shape === 'square';
if (isStoreHub) {
return `
<div class="map-popup-card">
<div class="map-popup-header">
<div class="map-popup-tag">
<span class="map-popup-badge-icon">🏪</span>
<span class="map-popup-id">Store Hub</span>
</div>
<span class="map-popup-step" style="background: #662582;">Origin</span>
</div>
<div class="map-popup-body">
<div class="map-popup-customer-row">
<div class="map-popup-avatar" style="background: rgba(102, 37, 130, 0.12); color: #662582; font-size: 13px;">🏪</div>
<div class="map-popup-cust-details">
<span class="map-popup-customer">${escapeHtml(p.title || 'R mart')}</span>
<span class="map-popup-subtitle" style="font-size: 11px; color: #64748b; font-weight: 500;">${escapeHtml(p.subtitle || 'Fulfillment & Dispatch Hub')}</span>
</div>
</div>
<div class="map-popup-address-box" style="display: flex; align-items: flex-start; gap: 5px; background: #f8fafc; padding: 6px 8px; border-radius: 6px; border: 1px solid #e2e8f0; font-size: 11px; color: #475569; line-height: 1.35;">
<span style="font-size: 12px; flex-shrink: 0; line-height: 1.2;">📍</span>
<span>${escapeHtml(p.address || 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002')}</span>
</div>
<div class="map-popup-status-bar">
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
<span class="map-popup-status-dot"></span>
<span class="map-popup-status-text">${escapeHtml(p.status || 'Active Origin Depot')}</span>
</span>
</div>
</div>
</div>
`;
}
return `
<div class="map-popup-card">
<div class="map-popup-header">
<div class="map-popup-tag">
<span class="map-popup-badge-icon">🛵</span>
<span class="map-popup-id">${escapeHtml(p.title || pin.label)}</span>
</div>
</div>
<div class="map-popup-body">
${p.subtitle ? `<div class="map-popup-customer">${escapeHtml(p.subtitle)}</div>` : ''}
${p.address ? `<div class="map-popup-address">${escapeHtml(p.address)}</div>` : ''}
${p.status ? `
<div class="map-popup-status-bar">
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
<span class="map-popup-status-dot"></span>
<span class="map-popup-status-text">${escapeHtml(p.status)}</span>
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
</span>
</div>
` : ''}
</div>
</div>
`;
}
// Fallback for default pins
return `
<div class="map-popup-card">
<div class="map-popup-header">
<span class="map-popup-id">${escapeHtml(pin.label)}</span>
</div>
<div class="map-popup-body">
${(pin.lines ?? []).map(line => `<div class="map-popup-line">${escapeHtml(line)}</div>`).join('')}
</div>
</div>
`;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Popup and icon HTML is a string, so a rider named `<b>` must not become markup. */
function escapeAttr(value: string): string {
return value.replace(/["'<>]/g, '');
}

140
src/components/kpiCard.css Normal file
View File

@@ -0,0 +1,140 @@
/* ══ The KPI tile ══════════════════════════════════════════════════════════
The console's headline figure, and the card design the rest of the site
follows: the same surface tokens, the same icon tile, the same
label / value / note hierarchy.
Layout is a stack: a top row carrying the label and the icon tile, then the
figure at full card width, then the note.
The figure is LEFT-aligned and owns the whole width, and both halves of that
matter. The old tile centred it, so the number sat in a different place in
every card depending on how many digits it had and a row of tiles never
lined up. The tile before this one put the icon in a column to the left,
which left about 134px for the figure at the width these actually get — and
"₹1,24,500" wrapped mid-number. */
.kpi-card {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
/* A floor, not a height. Labels wrap to two lines at narrow widths — with
Nearle Buddy open these are about 195px wide, and "Cancelled orders"
ellipsised to "Cance…" is a label that has stopped working — so the tile
grows and the row stays level. */
min-height: 104px;
padding: 16px;
/* The console's one card surface. Same tokens as `Panel` and every other
card in the app; see the note in `index.css`. */
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
box-shadow: var(--card-shadow);
}
/* ── The icon tile ──────────────────────────────────────────────────────────
A rounded square in the tone's tint, one step tighter than the card that
holds it — a control radius inside a card radius, which is what keeps it
from reading as a small card of its own. */
.kpi-card-icon {
width: 40px;
height: 40px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--card-radius-sm);
background: var(--kpi-tint);
color: var(--kpi-ink);
}
/* Brand by default — a tile that is merely reporting should not shout. The
other three are for tiles whose figure IS a state. */
.kpi-card {
--kpi-ink: var(--color-brand);
--kpi-tint: var(--color-brand-tint);
}
.kpi-card[data-tone='success'] {
--kpi-ink: var(--color-success, #1f9d55);
--kpi-tint: color-mix(in oklab, var(--color-success, #1f9d55) 12%, transparent);
}
.kpi-card[data-tone='warning'] {
--kpi-ink: var(--color-warning, #b7860b);
--kpi-tint: color-mix(in oklab, var(--color-warning, #b7860b) 14%, transparent);
}
.kpi-card[data-tone='error'] {
--kpi-ink: var(--color-error, #d64545);
--kpi-tint: color-mix(in oklab, var(--color-error, #d64545) 12%, transparent);
}
/* ── The top row ────────────────────────────────────────────────────────── */
/* Label left, icon right. The label takes the space the icon does not, and the
figure below gets the whole card. */
.kpi-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
min-width: 0;
margin-bottom: 8px;
}
/* The label names the figure and stays quiet doing it. Not caps: at 11px in
caps with letter-spacing, "Cancelled orders" runs wider than the tile and a
two-line eyebrow reads as a heading that broke. Sentence case, ink-3. */
.kpi-card-label {
font: 500 12.5px/1.3 var(--font-sans);
color: var(--color-ink-3);
min-width: 0;
/* Two lines, then ellipsis — never a third. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* The figure. Tabular so a column of tiles lines up on the digits, and pulled
in very slightly — at 25px the default tracking makes a rupee figure look
spaced out rather than set.
IT NEVER WRAPS. `overflow-wrap: anywhere` was on this rule and it broke
"₹1,24,500" across two lines between the 0 and the 0 — a figure split
mid-digit is not a smaller figure, it is a wrong one. A number too wide for
its tile ellipsises instead, which is visibly truncated rather than quietly
misread, and the `title` is not needed because the same figure is on the
page it links to. */
.kpi-card-value {
font: 700 25px/1.2 var(--font-sans);
letter-spacing: -0.02em;
color: var(--color-ink-1);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* What the figure is made of. This is the line that makes the number mean
something — see the note in `KpiCard.tsx` for why it had to come back. */
.kpi-card-note {
margin-top: 3px;
font: 400 11.5px/1.4 var(--font-sans);
color: var(--color-ink-4);
/* Two lines at most. "142 app · 312 counter" wraps at tile width and that is
fine; a third line would push the tile past its neighbours. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* `.kpi-grid` is NOT defined here. It already exists in `index.css`, built on
container queries rather than viewport ones — which is what lets a tile row
reflow when Nearle Buddy opens beside it and takes 340px out of the column,
something a viewport breakpoint cannot see. Defining a second one here would
have won on import order and silently thrown that away. */
@media (max-width: 600px) {
.kpi-card { min-height: 96px; padding: 14px; }
.kpi-card-icon { width: 32px; height: 32px; }
.kpi-card-value { font-size: 22px; }
}

141
src/components/panel.css Normal file
View File

@@ -0,0 +1,141 @@
/* ══ The panel — the console's one card ════════════════════════════════════
Three zones: a header band that says what this is and how much of it there
is, a body, and a footer band that says where you are in it. See the note at
the top of `Panel.tsx` for why the card grew an anatomy rather than a nicer
outline.
Every size here comes from the card tokens in `index.css`. */
.panel-card {
display: flex;
flex-direction: column;
min-width: 0;
border: var(--card-border);
border-radius: var(--card-radius);
background: var(--card-bg);
box-shadow: var(--card-shadow);
/* The clip belongs on the element that owns the radius, or the header band's
tint squares off the top two corners. */
overflow: hidden;
}
/* For a grid row of panels that should finish level with each other. */
.panel-card[data-filled='true'] { height: 100%; }
/* ── Header band ────────────────────────────────────────────────────────── */
/* Tinted rather than white, so the band reads as chrome and the rows below it
read as data. A white header separated only by a rule is the same surface
twice, which is how a heading ends up looking like a first table row. */
.panel-card-head {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-height: 48px;
padding: 8px 16px;
background: var(--color-surface-subtle);
border-bottom: var(--card-border);
}
.panel-card-title-group {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
/* An EYEBROW, not a heading. 11px caps: this labels the panel, and a 20px
title inside a card competes with the page's own title above it — the two
stacked is what made every screen say its subject twice. */
.panel-card-title {
margin: 0;
font: 600 11px/1.2 var(--font-sans);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-ink-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The count, in its own chip. Tabular, because it changes as a filter narrows
and a figure that shifts sideways while you read it is hard to trust. */
.panel-card-count {
display: inline-flex;
align-items: baseline;
gap: 4px;
flex: none;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-surface-sunken);
font: 600 11.5px/1.45 var(--font-sans);
font-variant-numeric: tabular-nums;
color: var(--color-ink-2);
}
.panel-card-count-label {
font-weight: 400;
font-size: 11px;
color: var(--color-ink-4);
}
.panel-card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}
/* ── Body ───────────────────────────────────────────────────────────────── */
.panel-card-body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.panel-card-body[data-pad='md'] { padding: 16px; }
/* ── Footer band ────────────────────────────────────────────────────────── */
.panel-card-foot {
flex: none;
padding: 8px 16px;
background: var(--color-surface-subtle);
border-top: var(--card-border);
}
/* The pager hides itself on a single page — it returns null — but the band
around it is rendered by the parent, which cannot see that. Without this a
short table carried a 44px strip of tinted chrome saying nothing.
`:has` collapses the band when its child rendered no DOM at all. */
.panel-card-foot:not(:has(*)) {
display: none;
}
/* The pager's own wrapper adds padding meant for sitting loose under a table.
Inside the band the band supplies it. */
.panel-card-foot .table-pager {
padding: 0;
border-top: 0;
}
/* ── A table sitting flush in the body ──────────────────────────────────────
The table brings its own horizontal scroll and its own header row. What it
must not bring is a rounded corner or an outer border — the panel owns both,
and a table with its own edge inside a panel is the double line this design
is meant to remove. */
.panel-card-body > .table-scroll {
border-radius: 0;
border: 0;
}
/* ── Narrow ─────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.panel-card-head { padding: 8px 12px; }
.panel-card-foot { padding: 8px 12px; }
.panel-card-body[data-pad='md'] { padding: 12px; }
/* Actions drop to their own line rather than squeezing the title out. */
.panel-card-actions { margin-left: 0; width: 100%; }
}

View File

@@ -0,0 +1,82 @@
/* ══ Search box ════════════════════════════════════════════════════════════
Shaped to match the view-tab track it sits opposite — see the note in
`SearchInput.tsx`. Every number here is the same number `tabBar.css` uses. */
.searchbox {
display: inline-flex;
align-items: center;
gap: 7px;
/* The track's height and the track's corner. This is the whole point of the
component: a search that is visibly the same object as the buttons at the
other end of the row. */
height: 38px;
padding: 0 10px;
border-radius: var(--card-radius-sm);
border: var(--card-border);
background: var(--card-bg);
box-shadow: var(--card-shadow);
max-width: 100%;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
/* Fills its container, for a box sitting directly above the list it filters. */
.searchbox[data-full='true'] { display: flex; width: 100%; }
.searchbox:hover { border-color: var(--color-ink-4); }
/* Focus lands on the BOX, not on the bare input inside it — the input has no
border of its own, so the default ring would draw inside the box and read as
a second, smaller field. */
.searchbox:focus-within {
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-tint);
}
.searchbox-icon { flex: none; color: var(--color-ink-4); }
.searchbox:focus-within .searchbox-icon { color: var(--color-brand); }
.searchbox-input {
flex: 1 1 auto;
min-width: 0;
border: 0;
padding: 0;
background: transparent;
color: var(--color-ink-1);
font: 400 13px/1 var(--font-sans);
outline: none;
/* Safari draws its own rounded field and inner shadow on a text input, and
`type="search"` adds a magnifier and a cancel button on top of ours. */
appearance: none;
-webkit-appearance: none;
}
.searchbox-input::placeholder { color: var(--color-ink-4); }
.searchbox-input::-webkit-search-decoration,
.searchbox-input::-webkit-search-cancel-button,
.searchbox-input::-webkit-search-results-button {
appearance: none;
-webkit-appearance: none;
display: none;
}
.searchbox-clear {
flex: none;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--color-surface-sunken);
color: var(--color-ink-3);
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.searchbox-clear:hover { background: var(--color-line); color: var(--color-ink-1); }
.searchbox-clear:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 1px; }
/* On a phone the header row wraps, and a 260px box beside a scrolling tab
track leaves neither enough room. It takes the full line instead. */
@media (max-width: 600px) {
.searchbox { width: 100% !important; }
}

View File

@@ -0,0 +1,824 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ChevronDown, ChevronLeft, LogOut, Menu, X } from 'lucide-react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
import { AssistantPanel } from './AssistantPanel';
import { DateScopePicker } from './DateScope';
export interface NavEntry {
to: string;
label: string;
}
/** One entry in the account menu's MANAGE group. */
export interface MenuEntry {
to: string;
label: string;
icon: ReactNode;
/** One line under the label, for an entry whose scope is not obvious. */
note?: string;
}
export interface AppShellProps {
/** Destinations for the header tabs and the mobile sheet. */
nav: readonly NavEntry[];
/** Where the logo links to — the workspace's own landing page. */
home: string;
/** Accessible name for the tab list, e.g. "Store Admin". */
navLabel: string;
/**
* An optional control between the logo and the tabs — the Store Admin's
* branch selector lives here. It sits inside the header rather than on each
* page because it scopes every page, and a control that moves between pages
* reads as a different control each time.
*/
scopeControl?: ReactNode;
/**
* Setup destinations, listed inside the account menu rather than in the nav.
*
* This follows the old console's judgement, and its reasoning holds: setup is
* configuration, not a place anyone works from day to day, and a nav slot
* spent on it is a slot taken from a section that IS worked from. The account
* menu is where "my workspace's setup" belongs, beside the account and
* sign-out.
*
* Listed as individual destinations rather than one "Settings" entry for the
* old console's other reason: a single entry lands everyone on the first
* section and makes them click again, and the sections are what people come
* here for.
*/
manageItems?: readonly MenuEntry[];
/**
* Workspace-specific controls in the header, left of the notification bell.
*
* For things a shop reaches from anywhere and that open in place rather than
* navigating — the store's QR code is the first. A page for it would have
* been a fifth destination for something that is looked at, printed once, and
* closed.
*/
headerActions?: ReactNode;
/**
* A full-width strip between the header and the page.
*
* The setup walkthrough lives here. It has to sit in the shell rather than
* on a page because it crosses several: one step is on Profile, the next on
* Users, the next on Inventory — anything page-local would vanish the moment
* somebody followed it.
*/
banner?: ReactNode;
}
/**
* The application chrome, shared by every workspace.
*
* Built to KROW's `AdminLayout` spec. Deliberately NOT a coloured slab: the
* header is a 56px white-at-85% bar with a backdrop blur and a hairline bottom
* border, carrying text tabs whose active state is accent-coloured text plus a
* 2px accent bar sitting on that border. The repo's own reasoning for text tabs
* over pills: several destinations means several competing shapes if each one
* is a pill, and a console header should recede rather than compete with the
* page.
*
* One component rather than one per workspace. The workspaces differ in exactly
* three things — their destinations, their home, and whether they have a scope
* control — so those are props. Copying four hundred lines of chrome per role
* is how two headers drift apart and stop looking like one product.
*/
export function AppShell({
nav,
home,
navLabel,
scopeControl,
manageItems,
headerActions,
banner,
}: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
const [isNavOpen, setIsNavOpen] = useState(false);
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.
useEffect(() => {
if (!isMenuOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setIsMenuOpen(false);
}
function onMouseDown(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsMenuOpen(false);
}
}
window.addEventListener('keydown', onKeyDown);
document.addEventListener('mousedown', onMouseDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
document.removeEventListener('mousedown', onMouseDown);
};
}, [isMenuOpen]);
return (
<div style={{ minHeight: '100vh' }}>
{/* Two elements, on purpose. The bar is full-bleed — the glass, the blur
and the hairline run the whole width of the screen, because a header
that stops short of the edge reads as a floating card, not as chrome.
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
on exactly the same left edge as the page title below them at every
width, including a 2560px monitor where the body is centred. */}
{/*
Opaque, with no backdrop blur.
The blur cost more than it bought the moment a popover moved into this
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
`position: fixed` descendant, and the design system's popovers are fixed
and CSS-anchor-positioned — so the date picker's calendar rendered
inside the header at 0×0 and could not be opened at all. The same
control worked perfectly two pixels lower, on the page.
A solid background is the fix rather than a hack around it: the bar sits
on a near-white page, so at 85% opacity plus blur it was already almost
opaque, and nothing here reads differently for losing it.
*/}
<header
style={{
position: 'sticky',
top: 0,
zIndex: 40,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
}}
>
<div
className="app-gutter"
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<Link
to={home}
aria-label={`${navLabel} — home`}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
textDecoration: 'none',
borderRadius: 12,
}}
>
{/* 24px tall, width auto — the reference pins the logo's height and
lets the wordmark set its own width. */}
<img
src="/logo-wordmark.png"
alt="Nearle"
style={{ height: 24, width: 'auto', display: 'block' }}
/>
</Link>
{/* Below md the scope control moves into the navigation sheet. On a
390px phone the logo, the selector and the right-hand cluster add
up to 503px and push the header 113px past the viewport, so the
page scrolls sideways. The sheet gives the branch names room to be
read in full, and every page states its own scope in the header
line beneath, so the phone never leaves the operator guessing. */}
{scopeControl ? (
<div className="show-from-md" style={{ flexShrink: 0 }}>
{scopeControl}
</div>
) : null}
{/* Below lg the tabs are gone, so a flexible spacer keeps the right
cluster against the edge instead of bunched beside the logo. */}
<div className="lg-hidden" style={{ flex: 1 }} />
{/* Text tabs. Active = accent text + a 2px accent bar on the header rule. */}
<nav
aria-label={navLabel}
className="show-from-lg"
style={{ alignItems: 'center', gap: 2, flex: 1, minWidth: 0 }}
>
{nav.map((entry) => {
const isActive = pathname.startsWith(entry.to);
return (
<NavLink
key={entry.to}
to={entry.to}
aria-current={isActive ? 'page' : undefined}
style={{
position: 'relative',
whiteSpace: 'nowrap',
borderRadius: 12,
padding: '6px 10px',
fontSize: 13,
lineHeight: '20px',
fontWeight: 500,
textDecoration: 'none',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
transition: 'color .2s cubic-bezier(.16,1,.3,1)',
}}
>
{entry.label}
{isActive ? (
<span
style={{
position: 'absolute',
left: 10,
right: 10,
bottom: -13,
height: 2,
borderRadius: 999,
background: 'var(--color-brand)',
}}
/>
) : null}
</NavLink>
);
})}
</nav>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{/* No global search.
There was a search box here with `⌘K` on it, and its `onSubmit`
was `preventDefault()` and nothing else — it advertised a
console-wide search that did not exist and had no endpoint
behind it. The per-page search boxes on Sales, Users, Stores and
the catalogue are real and stay. A control that looks like it
works costs more trust than a missing one. */}
{/* The date filter, beside the profile and common to every page.
Rendered here rather than by each page so the two questions the
console asks — which shop, and when — are both answered in the
chrome, and so the answer survives navigation. */}
<DateScopePicker />
{headerActions}
{/* No notification bell. It was labelled "2 unread" with the dot
painted unconditionally, for every user on every page, forever —
and it had no click handler and no notifications endpoint behind
it anywhere in the API. */}
{/* Assistant button moved to floating pill */}
{/* Avatar trigger + chevron, with the account menu below it. */}
<div ref={menuRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setIsMenuOpen((open) => !open)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 6px 4px 4px',
borderRadius: 12,
border: 0,
background: isMenuOpen ? 'var(--color-surface-sunken)' : 'transparent',
cursor: 'pointer',
}}
>
<span
style={{
width: 28,
height: 28,
borderRadius: 999,
background: 'var(--color-brand)',
color: '#fff',
display: 'grid',
placeItems: 'center',
fontSize: 11,
fontWeight: 600,
}}
>
{initials(user?.name ?? '')}
</span>
<ChevronDown size={12} style={{ color: 'var(--color-ink-3)' }} />
</button>
{isMenuOpen ? (
<div
role="menu"
style={{
position: 'absolute',
right: 0,
top: 40,
width: 268,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
borderRadius: 12,
boxShadow: '0 12px 28px -6px rgb(16 24 40 / .12)',
padding: 6,
zIndex: 50,
}}
>
<div style={{ padding: '8px 10px 10px' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{user?.name}
</div>
<div style={{ fontSize: 11.5, color: 'var(--color-ink-4)' }}>{user?.email}</div>
<div style={{ marginTop: 6 }}>
<span
style={{
display: 'inline-block',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
borderRadius: 999,
padding: '2px 8px',
fontSize: 11,
fontWeight: 600,
}}
>
{user ? ROLE_LABEL[user.role] : ''}
</span>
</div>
</div>
{manageItems && manageItems.length > 0 ? (
<>
<Rule />
<p
style={{
margin: 0,
padding: '6px 12px 4px',
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--color-ink-4)',
}}
>
Manage
</p>
{manageItems.map((entry) => (
<MenuLink
key={entry.to}
entry={entry}
onNavigate={() => setIsMenuOpen(false)}
/>
))}
</>
) : null}
<Rule />
<button
type="button"
onClick={signOut}
role="menuitem"
style={{
display: 'flex',
width: '100%',
alignItems: 'center',
gap: 10,
padding: '9px 12px',
borderRadius: 12,
border: 0,
background: 'transparent',
// Sign out is the one destructive-adjacent item here, and
// it sits last so a mis-aimed click lands on nothing.
color: 'var(--color-error, #d64545)',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
textAlign: 'left',
}}
>
<LogOut size={15} />
Sign out
</button>
{/* When this code was built. If it does not match what you
were told was delivered, you are looking at an old copy —
which is not something any screen otherwise reveals. */}
<p
style={{
margin: '6px 12px 2px',
fontSize: 10.5,
color: 'var(--color-ink-4)',
fontVariantNumeric: 'tabular-nums',
}}
>
Build {__BUILD_STAMP__}
</p>
</div>
) : null}
</div>
{/* Wrapped rather than styled inline: an inline `display` would win
over the media query that hides this above the tabs breakpoint. */}
<span className="lg-hidden">
<IconButton label="Open navigation" onClick={() => setIsNavOpen(true)}>
<Menu size={16} />
</IconButton>
</span>
</div>
</div>
</header>
{/* Body: a column on a phone so the assistant stacks under the page, a
row from md where it becomes a side column. */}
{banner}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
{/* Scoped to the page, not the shell: a page that throws should leave
the nav, the account menu and the workspace switcher usable, so
you can walk to a screen that works instead of reloading blind.
Keyed by pathname so navigating away clears a caught error —
without that, one broken page latches the whole outlet. */}
<ErrorBoundary key={pathname} area="this page">
<Outlet />
</ErrorBoundary>
</main>
{isAssistantOpen || isMobile ? (
<AssistantPanel onClose={() => setIsAssistantOpen(false)} isStacked={isMobile} />
) : null}
</div>
{/* Floating Assistant Button */}
{!isMobile && !isAssistantOpen ? (
<button
type="button"
onClick={() => setIsAssistantOpen(true)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 50,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '6px 12px 6px 6px',
borderRadius: 999,
background: 'var(--color-surface)',
border: '1px solid var(--color-line)',
boxShadow: '0 8px 24px -4px rgba(0,0,0,0.1), 0 4px 10px -2px rgba(0,0,0,0.05)',
cursor: 'pointer',
transition: 'transform 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-2px)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--color-surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
overflow: 'hidden',
}}
>
<img src="/icon-192.png" alt="Nearle logo" style={{ width: 24, height: 24, objectFit: 'contain', borderRadius: '50%' }} />
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink-1)' }}>
Nearle Buddy
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 2,
borderRadius: 6,
border: '1px solid var(--color-line)',
color: 'var(--color-ink-3)',
}}
>
<ChevronLeft size={14} />
</div>
</button>
) : null}
{isNavOpen ? (
<MobileNav
nav={nav}
scopeControl={scopeControl}
onClose={() => setIsNavOpen(false)}
pathname={pathname}
/>
) : null}
</div>
);
}
/**
* The navigation sheet, below lg.
*
* 288px from the right, opaque rather than glass — a translucent sheet over a
* page of tables is unreadable — with the active item carried by a tinted fill
* instead of the 2px underline, which has nothing to sit on here.
*/
function MobileNav({
nav,
scopeControl,
onClose,
pathname,
}: {
nav: readonly NavEntry[];
scopeControl?: ReactNode;
onClose: () => void;
pathname: string;
}) {
return (
<div
role="dialog"
aria-modal="true"
aria-label="Navigation"
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
display: 'flex',
justifyContent: 'flex-end',
}}
>
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgb(15 23 42 / .35)',
cursor: 'pointer',
}}
/>
<div
style={{
position: 'relative',
width: 288,
maxWidth: '85vw',
height: '100%',
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-line)',
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottom: '1px solid var(--color-line)',
}}
>
<span style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 600 }}>
Menu
</span>
<IconButton label="Close navigation" onClick={onClose}>
<X size={16} />
</IconButton>
</div>
{scopeControl ? (
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--color-line)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-ink-4)',
}}
>
Showing
</span>
{scopeControl}
</div>
) : null}
<nav style={{ display: 'flex', flexDirection: 'column', padding: 8, gap: 2 }}>
{nav.map((entry) => {
const isActive = pathname.startsWith(entry.to);
return (
<NavLink
key={entry.to}
to={entry.to}
onClick={onClose}
style={{
padding: '10px 12px',
borderRadius: 12,
fontSize: 13.5,
fontWeight: 500,
textDecoration: 'none',
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-2)',
}}
>
{entry.label}
</NavLink>
);
})}
</nav>
</div>
</div>
);
}
/**
* One destination in the account menu.
*
* A `NavLink` rather than a button with `navigate()`: middle-click, ⌘-click and
* "open in new tab" all work on a real anchor and none of them work on a
* button, and a setup screen is exactly the kind of thing someone parks in a
* second tab.
*/
function MenuLink({ entry, onNavigate }: { entry: MenuEntry; onNavigate: () => void }) {
const [isHovered, setIsHovered] = useState(false);
return (
<NavLink
to={entry.to}
role="menuitem"
onClick={onNavigate}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
borderRadius: 12,
textDecoration: 'none',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
color: 'var(--color-ink-1)',
transition: 'background .15s',
}}
>
<span
style={{
width: 26,
height: 26,
borderRadius: 8,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: isHovered ? 'var(--color-brand-tint)' : 'var(--color-surface-sunken)',
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-3)',
transition: 'background .15s, color .15s',
}}
>
{entry.icon}
</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 500 }}>{entry.label}</span>
{entry.note ? (
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}>
{entry.note}
</span>
) : null}
</span>
</NavLink>
);
}
const Rule = () => (
<div style={{ height: 1, background: 'var(--color-line)', margin: '5px 0' }} />
);
/**
* Up to two letters for the avatar. Never a dash.
*
* First name and last name where the account has them. Where it does not —
* plenty of rows on this backend carry neither — the email is used instead,
* split on the separators people actually put in addresses, so
* `ragul.kumar@shop.in` gives RK and `care@nearle.in` gives C.
*
* The old version returned an em dash for anything it could not parse, and that
* is what every Store Admin and Store user saw: their accounts have no first or
* last name, so the avatar was a dash on every page. A dash says nothing and
* looks like a bug, which is worse than one letter.
*/
function initials(name: string): string {
const trimmed = name.trim();
if (trimmed === '') return '?';
// An email is not a name. Take the part before the @ and read the words out
// of it — a full address would otherwise give the domain's letter as the
// second initial.
const source = trimmed.includes('@') ? (trimmed.split('@')[0] ?? trimmed) : trimmed;
const words = source
.split(/[\s._\-+]+/)
.map((word) => word.replace(/[^\p{L}\p{N}]/gu, ''))
.filter(Boolean);
if (words.length === 0) return '?';
const first = words[0]?.[0] ?? '';
const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : '';
return (first + last).toUpperCase();
}
/**
* A 32px icon-only control.
*
* `label` is required, not optional — an icon-only control with no accessible
* name is a bug, so the API makes it impossible to omit.
*/
export function IconButton({
label,
children,
onClick,
hasUnread,
}: {
label: string;
children: ReactNode;
onClick?: () => void;
hasUnread?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
position: 'relative',
display: 'grid',
placeItems: 'center',
width: 32,
height: 32,
borderRadius: 12,
border: 0,
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
color: isHovered ? 'var(--color-ink-1)' : 'var(--color-ink-3)',
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{children}
{hasUnread ? (
<span
style={{
position: 'absolute',
right: 4,
top: 4,
width: 8,
height: 8,
borderRadius: 999,
background: 'var(--color-brand)',
boxShadow: '0 0 0 2px #fff',
}}
/>
) : null}
</button>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import type { DateRange } from '@/api/insights';
import {
DateRangePicker,
presetRange,
type RangePreset,
} from '@/features/store-admin/DateRangePicker';
/**
* The console's date filter, held once for the whole workspace.
*
* It sits in the top bar beside the profile, not on the page, and every page
* reads it from here — the same relationship `BranchScope` already has to the
* branch picker beside the logo. The two scopes now work the same way: the two
* questions every page is asked, "which shop" and "when", are answered once in
* the chrome rather than re-answered on each screen.
*
* ── What this changes about the pages ───────────────────────────────────────
*
* The range survives navigation. Setting March on Sales and clicking through to
* Reports shows March, which is what somebody looking into a month actually
* wants and is the whole reason for lifting it. It also means the live boards —
* Console, Counters, Terminals — no longer force themselves back to today; they
* follow the shared range like everything else. That is the trade the move
* makes, and it is the right one, but it IS a change: those three used to be
* pinned to the day whatever else you had chosen.
*/
export interface DateScopeValue {
preset: RangePreset;
/**
* What the PAGES read — always a real window.
*
* When nobody has picked anything this is the default week, not an empty
* object. Left empty, every read on the console became unbounded and each
* part of a page bounded it differently: the Console's chart drew however
* many days the last 500 orders happened to span (nine, in practice) while
* the KPI tiles beside it totalled all of them, and the counter figures came
* from a POS summary with no window at all. One page, three answers.
*/
range: DateRange;
/**
* What the PICKER shows — empty until somebody chooses.
*
* This is the half that keeps the filter unselected on arrival. The two are
* separate on purpose: "no filter has been set" is a fact about the control,
* and "which week are we looking at" is a fact about the data, and collapsing
* them into one value is what forced a choice between a filter nobody set and
* a page with no window.
*/
chosen: DateRange;
set: (preset: RangePreset, range: DateRange) => void;
/** Back to no filter at all — what an empty state offers as a way out. */
clear: () => void;
/** True when the USER has narrowed the page, not merely that a window exists. */
isFiltered: boolean;
}
/**
* The window the console shows when nobody has picked one.
*
* A WEEK — while the picker still shows nothing selected. Those are two
* separate statements and both are deliberate.
*
* The filter opens unselected because the console used to narrow every page
* before anyone asked: somebody signing in to see how trade is going was shown
* a slice with no sign that it was one, and the range then followed them across
* the whole console.
*
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
* capped only by `pagesize`, and each part of a page then bounded itself
* differently — the Console's chart drew however many days the last 500 orders
* happened to span (nine), the KPI tiles beside it totalled all of those
* orders, and the counter figures came from a POS summary with no window
* whatsoever. One page, three different answers to "when".
*
* A default window is not a filter. Nothing is hidden from the reader, the
* control is empty, and one choice replaces it.
*/
const DEFAULT_WINDOW: RangePreset = 'week';
const DateScopeContext = createContext<DateScopeValue | null>(null);
export function DateScopeProvider({ children }: { children: ReactNode }) {
/* What the user picked. Empty until they do — this is what the picker shows. */
const [preset, setPreset] = useState<RangePreset>('custom');
const [chosen, setChosen] = useState<DateRange>({});
const value = useMemo<DateScopeValue>(() => {
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
return {
preset,
chosen,
/* The window the pages actually read: what was picked, or the default
week. Resolved here, once, so every read on a page shares it — the
orders, the POS summaries and the chart cannot end up describing
different spans. */
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
set: (nextPreset, nextRange) => {
setPreset(nextPreset);
setChosen(nextRange);
},
clear: () => {
setPreset('custom');
setChosen({});
},
isFiltered: hasChoice,
};
}, [preset, chosen]);
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
}
/**
* The shared range.
*
* Throws outside the provider rather than inventing a local range: a page that
* silently filtered on its own dates while the bar showed something else would
* be the exact confusion this exists to remove.
*/
export function useDateScope(): DateScopeValue {
const value = useContext(DateScopeContext);
if (!value) throw new Error('useDateScope must be used inside a DateScopeProvider');
return value;
}
/** The control itself. Rendered once, in the top bar. */
export function DateScopePicker() {
const dates = useDateScope();
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
}

View File

@@ -0,0 +1,91 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { CONTEXT, matchAssistantRoute } from './assistantContext';
/*
The bug these exist for: the console has THREE workspaces — `/nearle` for
platform staff, `/admin` for a merchant, `/store` for a branch user — and this
map only knew about two. A store user therefore got the fallback on every page,
and the fallback carries no agent, so the composer was dead for that entire role
however the deployment was configured.
Nothing caught it because `/store/console` and `/admin/console` render the same
component and look identical on screen, down to the heading. The only difference
is the pathname, which is precisely the input this map takes.
*/
/** The routes that must be able to answer, per workspace. */
const MUST_HAVE_AN_AGENT = [
'/admin/console',
'/admin/sales',
'/admin/inventory',
'/store/console',
'/store/sales',
];
test('every workspace that can answer, does', () => {
for (const pathname of MUST_HAVE_AN_AGENT) {
const { context } = matchAssistantRoute(pathname);
assert.ok(
context.agent,
`${pathname} has no agent, so its composer is disabled whatever the backend says`,
);
}
});
test('the two console pages answer with the same agent', () => {
// `/admin/console` and `/store/console` are the same page at two scopes. One
// of them carrying an agent and the other not is the exact shape of the bug,
// and it is invisible in the rendered output.
const merchant = matchAssistantRoute('/admin/console').context;
const branch = matchAssistantRoute('/store/console').context;
assert.equal(branch.agent, merchant.agent);
assert.equal(matchAssistantRoute('/store/sales').context.agent, 'orders');
});
test('a page falls through to the fallback rather than to nothing', () => {
const { key, context } = matchAssistantRoute('/somewhere/unmapped');
assert.equal(key, undefined);
assert.equal(context.agent, undefined);
// The fallback must stay agentless. Giving it one would point every unmapped
// page at an assistant that cannot answer what is on it.
assert.ok(context.prompts.length > 0);
});
test('a deeper path still finds its page', () => {
// `/admin/sales/4412` is still the Sales panel. Exact matching would drop the
// agent the moment anybody opened a row.
assert.equal(matchAssistantRoute('/admin/sales/4412').context.agent, 'orders');
assert.equal(matchAssistantRoute('/store/console?branch=3').context.agent, 'console');
});
test('no key shadows another', () => {
// First match wins, in insertion order, so a key that is a prefix of another
// silently swallows it — adding a bare `/store` above `/store/console` would
// take the agent away from the console page and nothing would fail.
const keys = Object.keys(CONTEXT);
for (const key of keys) {
for (const other of keys) {
if (key === other) continue;
assert.ok(
!other.startsWith(key),
`${key} is a prefix of ${other}; whichever is declared first wins and the other is unreachable`,
);
}
}
});
test('every agent named here exists on the backend', () => {
// The agents are YAML in backend_fiesta/services/agents. A name that does not
// match one is refused at the door, and the person sees a failed question
// rather than a disabled composer — worse, because it looks like a fault.
const shipped = new Set(['console', 'orders', 'inventory', 'shopfloor', 'platform']);
for (const [route, context] of Object.entries(CONTEXT)) {
if (!context.agent) continue;
assert.ok(shipped.has(context.agent), `${route} names an agent that does not exist: ${context.agent}`);
}
});

View File

@@ -0,0 +1,280 @@
/**
* 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;
}
> = {
/* ── Nearle Admin ─────────────────────────────────────────────────────────
*
* The `platform` agent, which holds ONE tool: `help`.
*
* Not a reduced version of the merchant agents — the only one a platform
* account can use. `issuperadmin` reads across every merchant and belongs to
* none, so it carries no tenant, and all eight tools that read a shop's data
* declare `RequiresTenant` or `RequiresBranch` and refuse it. Giving this
* workspace those tools would produce a composer that accepts every question
* and answers "pick a shop first" to all of them.
*
* The chips changed with it, and had to. They used to ask "Which tenants have
* no branches?" and "Summarise platform growth" — questions no tool here can
* answer. The rule above holds: a chip that comes back "I cannot look that
* up" reads as a broken assistant rather than an unbuilt feature.
*
* The corpus now carries platform passages as well as the merchant ones —
* what creating a branch produces, what assigning a delivery partner does,
* what a district is, and why a shop's own figures are not reachable from
* here. The chips ask those.
*/
'/nearle/stores': {
agent: 'platform',
page: 'Stores',
title: 'Nearle Buddy',
greeting: 'Answers about how Nearle works — not about any one shop’s numbers.',
reading: 'Reads the help material. A shop’s own data is reachable only from inside its console.',
prompts: [
'What does creating a branch actually create?',
'Why can I not see a shop’s orders from here?',
'How does a shop get riders?',
'What is the difference between a till account and a console login?',
],
},
'/nearle/onboard/tenant': {
agent: 'platform',
page: 'Onboard tenant',
title: 'Provisioning a tenant',
greeting: 'A branch is never created alone — it arrives with somebody who can run it.',
reading: 'Reads the help material on onboarding, accounts and delivery regions.',
prompts: [
'What does creating a branch actually create?',
'New outlet cannot sign in',
'What is a district?',
'How does a shop get riders?',
],
},
'/nearle/catalogue': {
agent: 'platform',
page: 'Global catalogue',
title: 'Stocking a store',
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
reading: 'Reads the help material on how the catalogue reaches a shop’s shelf.',
prompts: [
'What does re-importing a product do?',
'Why is a product in my catalogue but not on the shelf?',
'Why can I not see a shop’s stock from here?',
],
},
/* ── Store Admin ──────────────────────────────────────────────────────── */
'/admin/console': {
agent: 'console',
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.',
prompts: [
'Which branch is underperforming?',
'Any tills not syncing?',
'What needs my approval?',
'Where is stock running out?',
],
},
'/admin/sales': {
agent: 'orders',
page: 'Sales',
title: 'Orders and deliveries',
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
prompts: [
'Why is the cancel rate high?',
'Which orders are stuck?',
'Compare online and counter sales',
'What is out for delivery?',
],
},
'/admin/inventory': {
agent: 'inventory',
page: 'Inventory',
title: 'Catalogue and stock',
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
prompts: [
'What is waiting for approval?',
'What is low or out of stock?',
'What has run out completely?',
'Which branch is waiting on most stock?',
],
},
'/admin/users': {
page: 'Users & access',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Would cover your back-office directory and the till accounts at each branch.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
'/admin/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Compare my branches',
'Online versus counter revenue',
'What is my inventory worth?',
],
},
/* ── Store user ───────────────────────────────────────────────────────────
*
* One branch, fixed. The pages are largely the SAME components the merchant
* sees — `/store/console` and `/admin/console` are both `ConsolePage` — so
* the absence of these entries was invisible in the code and total in the
* product: a store-manager got the fallback on every route, and the fallback
* has no agent, so the composer was dead for that whole role no matter what
* the deployment had configured.
*
* It stayed hidden because every test signed in as one of the other two
* roles, and because `/admin/console` and `/store/console` look identical on
* screen down to the heading.
*
* The wording differs from the merchant's on purpose. "Which branch is
* underperforming?" is not a question a branch user can act on, and the
* answers are scoped to their one branch by the session regardless — so the
* chips say what that scope actually is rather than implying a choice.
*/
'/store/console': {
agent: 'console',
page: 'Console',
title: 'Your branch today',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Would cover this branch — sales by channel, stock health, till status and what is waiting.',
prompts: [
'Any tills not syncing?',
'Where is stock running out?',
'What needs attention?',
'How is today going?',
],
},
'/store/sales': {
agent: 'orders',
page: 'Sales',
title: 'Orders and deliveries',
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
reading: 'Would cover this branch — orders, counter bills and delivery progress.',
prompts: [
'Which orders are stuck?',
'What is out for delivery?',
'Compare online and counter sales',
'Why is the cancel rate high?',
],
},
'/store/products': {
// No agent, and not an oversight. The `inventory` agent carries
// `approve_stock_request`, and whether a branch user may approve the stock
// their own branch asked for is a question about who is allowed to spend,
// not about which page this is. Until that is settled the page keeps the
// prompts and loses the composer, which is the honest of the two.
page: 'Products',
title: 'Catalogue and stock',
greeting: 'A product in the catalogue does not mean stock on the shelf — that takes an approved request.',
reading: 'Would cover this branch — what is on the shelf and what has been asked for.',
prompts: [
'What is low or out of stock?',
'What has run out completely?',
'How do I request stock?',
'Why is my request still pending?',
],
},
'/store/reports': {
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Would cover this branch — revenue by channel, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Online versus counter revenue',
'What sold best this week?',
'What is my stock worth?',
],
},
'/store/staff': {
page: 'Staff',
title: 'Who can sign in',
greeting: 'A till account and a console login are two different things — a cashier has no console access at all.',
reading: 'Would cover the people at this branch and the till accounts they sign in with.',
prompts: [
'What is the difference between the two?',
'How do I add a cashier?',
'Why can I not delete someone?',
'Who is inactive?',
],
},
};
const FALLBACK: (typeof CONTEXT)[string] = {
page: 'Console',
title: 'Good afternoon',
greeting: 'Ask about anything on this page.',
reading: 'Would cover this page.',
prompts: ['What needs attention?', 'Summarise this page'],
};
/** One route's panel copy. */
export type AssistantRouteContext = (typeof CONTEXT)[string];
/**
* Which entry a pathname lands on, and its key.
*
* Prefix matching, so `/admin/sales/4412` is still the Sales panel. The key is
* returned alongside because the panel clears its thread when it changes — two
* different pages must not share a conversation.
*
* First match wins, in insertion order, which is safe only while no key is a
* prefix of another. That is asserted in the tests rather than left as a
* property somebody has to notice.
*/
export function matchAssistantRoute(pathname: string): {
key: string | undefined;
context: AssistantRouteContext;
} {
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
return { key, context: (key ? CONTEXT[key] : undefined) ?? FALLBACK };
}

View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';
/**
* What Nearle Buddy is currently answering about.
*
* The panel names its own scope in its heading — "Across your branches" — and
* that heading is a promise about which data an answer would draw on. When an
* admin narrows to one branch, or a store user opens the console at all, the
* promise is wrong: the board beneath says R mart and the panel above still
* says every branch.
*
* So the SCOPE is published here and the panel reads it. Nothing else about
* Buddy changes — not its width, its controls, its prompts or its placement.
* A page that has no branch scope publishes nothing and the panel keeps the
* per-route wording it has always used.
*/
export const AssistantScopeContext = createContext<string | undefined>(undefined);
export function useAssistantScope(): string | undefined {
return useContext(AssistantScopeContext);
}

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useState } from 'react';
/**
* How wide Nearle Buddy is, remembered.
*
* The panel is a layout column, not an overlay — the page reflows beside it —
* so its width is a trade the operator makes against their own screen, and the
* right answer differs per person and per monitor. Someone on a 1280 laptop
* reading a wide table wants it narrow; someone on a 27" wants a conversation
* they can read.
*
* Kept in `localStorage` rather than in the session or the URL: it is a comfort
* setting for this browser on this machine, it should survive a reload and a
* sign-out, and it should NOT travel in a link — a pasted link that also
* resized the recipient's panel would be a surprise.
*/
const KEY = 'nearle.buddy.width.v1';
/** The CSS default, matched to `.assistant` in `index.css`. */
export const DEFAULT_WIDTH = 380;
/**
* The floor and ceiling, taken from KROW rather than invented.
*
* Its assistant column is 340-520 by drag with a 380 default, and the expanded
* state goes to 620 capped at 42% of the viewport. Matching those numbers is
* the point of having a design system: below 340 the prompt chips stop fitting
* on one line, and past 520 the page the panel is meant to be *about* loses its
* measure.
*/
export const MIN_WIDTH = 340;
export const MAX_WIDTH = 520;
/** The one-click expanded width, and the share of the viewport it may take. */
export const EXPANDED_WIDTH = 620;
export const MAX_VIEWPORT_SHARE = 0.42;
export function clampWidth(value: number, viewport: number): number {
const ceiling = Math.min(
MAX_WIDTH,
Math.max(MIN_WIDTH, Math.round(viewport * MAX_VIEWPORT_SHARE)),
);
if (!Number.isFinite(value)) return DEFAULT_WIDTH;
return Math.min(ceiling, Math.max(MIN_WIDTH, Math.round(value)));
}
/** The expanded width, under the same viewport share. */
export function expandedWidth(viewport: number): number {
return Math.min(EXPANDED_WIDTH, Math.round(viewport * MAX_VIEWPORT_SHARE));
}
function read(): number {
try {
const raw = window.localStorage.getItem(KEY);
if (raw === null) return DEFAULT_WIDTH;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : DEFAULT_WIDTH;
} catch {
// Private windows and blocked site data throw on access, not on read.
return DEFAULT_WIDTH;
}
}
export function useAssistantWidth() {
const [width, setWidthState] = useState<number>(() =>
typeof window === 'undefined' ? DEFAULT_WIDTH : clampWidth(read(), window.innerWidth),
);
const setWidth = useCallback((next: number, options?: { persist?: boolean }) => {
const clamped = clampWidth(next, window.innerWidth);
setWidthState(clamped);
if (options?.persist === false) return;
try {
window.localStorage.setItem(KEY, String(clamped));
} catch {
// Not being able to remember it is not a reason to refuse to resize it.
}
}, []);
const reset = useCallback(() => setWidth(DEFAULT_WIDTH), [setWidth]);
// A window narrowed after the width was set would otherwise leave the panel
// eating the page. Re-clamped on resize, but never written back — the stored
// preference is what they chose on a big screen and should return with it.
useEffect(() => {
function onResize() {
setWidthState((current) => clampWidth(current, window.innerWidth));
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return { width, setWidth, reset };
}

View File

@@ -0,0 +1,70 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { composerHint, composerNote } from './AssistantPanel';
/*
The panel carried a hardcoded sentence under the composer — "Not connected yet
— there is no assistant service behind this panel." — written before there was
an API behind it and never removed once there was. It had no condition on it, so
it rendered in every state on every page, and it said Buddy was off while the
placeholder three lines above said "Ask about this page".
That cost real debugging time: the panel was read as evidence the backend had no
model, on a deployment whose backend was answering questions.
So both lines are asserted together here. The rule is that they agree.
*/
const CHECKING = null;
const LIVE = true;
const OFF = false;
test('the note and the placeholder never disagree', () => {
// Whenever the note claims Buddy is not connected, the placeholder must say
// the same, and vice versa. Disagreeing is the bug, in either direction.
for (const available of [CHECKING, LIVE, OFF]) {
for (const hasAgent of [true, false]) {
const note = composerNote(available, hasAgent);
const hint = composerHint(available, hasAgent, false);
assert.equal(
note.startsWith('Not connected yet'),
hint === 'Not connected yet',
`note ${JSON.stringify(note)} disagrees with placeholder ${JSON.stringify(hint)}`,
);
}
}
});
test('a working assistant does not announce that it is disconnected', () => {
// The regression itself.
const note = composerNote(LIVE, true);
assert.ok(!note.includes('Not connected'), `a live panel still said: ${note}`);
assert.equal(composerHint(LIVE, true, false), 'Ask about this page');
});
test('an unconfigured deployment says so', () => {
assert.match(composerNote(OFF, true), /Not connected yet/);
assert.match(composerNote(OFF, false), /Not connected yet/);
});
test('a page with no agent says it is the page, not the deployment', () => {
// Two different facts, and only one of them is something the person can do
// anything about: they can move to a page that has an assistant.
const note = composerNote(LIVE, false);
assert.ok(!note.includes('Not connected'));
assert.match(note, /no assistant for this page/i);
});
test('nothing is claimed while the check is still in flight', () => {
// A sentence that appears and is replaced 200ms later reads as a flicker, and
// the one it used to show was the wrong one.
assert.equal(composerNote(CHECKING, true), '');
assert.equal(composerHint(CHECKING, true, false), 'Checking…');
});
test('asking outranks everything in the placeholder', () => {
assert.equal(composerHint(LIVE, true, true), 'Thinking…');
});

165
src/components/tabBar.css Normal file
View File

@@ -0,0 +1,165 @@
/* ══ The view switcher ═════════════════════════════════════════════════════
A segmented control: a recessed track, and the selected view raised out of
it onto the card surface. See the note in `TabBar.tsx` for why this replaced
six private copies of a transparent pill. */
.tabbar {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: var(--card-radius-sm);
background: var(--color-surface-sunken);
/* The track is a control, so it takes the control radius rather than the
card one, and it never stretches: a segmented control that fills the page
width puts its two tabs at either end of the screen. */
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.tabbar::-webkit-scrollbar { display: none; }
.tabbar-tab {
display: inline-flex;
align-items: center;
gap: 7px;
flex: none;
height: 32px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: calc(var(--card-radius-sm) - 3px);
background: transparent;
color: var(--color-ink-3);
font: 500 13px/1 var(--font-sans);
white-space: nowrap;
cursor: pointer;
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
}
/* Hover is a hint of the track's own surface, not the selected state — an
unselected tab that looks selected on hover is the oldest bug in this
control. */
.tabbar-tab:hover:not([aria-selected='true']) {
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
color: var(--color-ink-1);
}
/* Raised out of the track: the card surface, a hairline and the same 1px
micro-lift every card in the console carries. That is what makes it read as
sitting ON the track rather than tinted inside it. */
.tabbar-tab[aria-selected='true'] {
background: var(--card-bg);
border-color: var(--color-line);
box-shadow: var(--card-shadow);
color: var(--color-ink-1);
font-weight: 600;
}
.tabbar-tab:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
/* The icon carries the brand on the selected tab and nothing on the others, so
colour marks the selection once rather than three times. */
.tabbar-icon {
display: inline-flex;
flex: none;
color: var(--color-ink-4);
}
.tabbar-tab[aria-selected='true'] .tabbar-icon { color: var(--color-brand); }
.tabbar-label { min-width: 0; }
/* ── The count ──────────────────────────────────────────────────────────────
A figure, not a badge.
Three of the six copies filled this with `--color-warning` — the amber this
console uses for something needing attention — so a tab reading "Orders 85"
announced eighty-five problems. It is set as a quiet tabular number that
takes the brand only on the selected tab, which is the one whose count the
reader is actually acting on. */
.tabbar-count {
flex: none;
font: 600 11.5px/1 var(--font-sans);
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
padding: 3px 6px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-ink-4) 10%, transparent);
}
.tabbar-tab[aria-selected='true'] .tabbar-count {
color: var(--color-brand);
background: var(--color-brand-tint);
}
/* ── Live dot ───────────────────────────────────────────────────────────────
For a view that updates by itself. Green rather than the brand, because it
reports a STATE — the data is moving — and the brand in this console carries
action and identity. It keeps its colour on the selected tab for the same
reason. */
.tabbar-live {
flex: none;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-success, #1f9d55);
animation: tabbar-pulse 1.8s ease-in-out infinite;
}
@keyframes tabbar-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* A pulse is decoration; the dot itself is the information, so it stays. */
@media (prefers-reduced-motion: reduce) {
.tabbar-live { animation: none; }
}
/* ── Group divider ──────────────────────────────────────────────────────────
A hairline inside the track, for a tab that belongs to a different family
from the one before it — Counter sales beside the app's own Orders and
Deliveries. Drawn as a margin plus a border so it sits in the gap rather
than on either tab. */
.tabbar-tab[data-group-start='true'] {
margin-left: 9px;
position: relative;
}
.tabbar-tab[data-group-start='true']::before {
content: '';
position: absolute;
left: -6px;
top: 6px;
bottom: 6px;
width: 1px;
background: var(--color-line);
}
/* ── Secondary strip ────────────────────────────────────────────────────────
Under a primary switcher — a status filter, say. Smaller, and with no track
of its own, so two rows of tabs do not read as two controls of equal rank. */
.tabbar[data-size='sm'] {
background: transparent;
padding: 0;
gap: 4px;
}
.tabbar[data-size='sm'] .tabbar-tab {
height: 28px;
padding: 0 10px;
font-size: 12.5px;
border-radius: var(--card-radius-sm);
}
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] {
background: var(--color-brand-tint);
border-color: transparent;
box-shadow: none;
color: var(--color-brand);
}
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] .tabbar-count {
background: color-mix(in oklab, var(--color-brand) 16%, transparent);
}
.tabbar[data-size='sm'] .tabbar-icon { display: none; }
@media (max-width: 600px) {
.tabbar-tab { padding: 0 10px; gap: 6px; }
.tabbar-icon { display: none; }
}

View File

@@ -0,0 +1,24 @@
/* ══ The pager under a table ═══════════════════════════════════════════════
Sits inside the table's own card, below the last row, so it reads as part of
the table rather than as a separate control floating beneath it. */
.table-pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
padding: 8px 12px;
/* The rule is the seam between the last row and the controls. Rows already
draw their own bottom border, so this only shows where the table ends. */
border-top: 1px solid var(--color-line);
}
/* On a narrow window the count and the buttons stop fitting side by side.
Centred rather than left-aligned once wrapped, so the two lines read as one
block instead of a ragged edge. */
@media (max-width: 560px) {
.table-pager {
justify-content: center;
flex-wrap: wrap;
}
}

406
src/components/trailMap.css Normal file
View File

@@ -0,0 +1,406 @@
/**
* The map's frame and its pins.
*
* Leaflet ships its own stylesheet for the tiles, controls and popups; this
* covers only what the console adds — the container, the divIcon pins and the
* empty state — plus the two places leaflet's defaults clash with the console.
*/
.trail-map {
position: relative;
z-index: 0;
width: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-surface-sunken);
}
.trail-map-canvas {
width: 100%;
height: 100%;
}
/* Sits over the tiles rather than replacing them: an empty map still shows the
region, so "no positions here" reads as an absence in a real place instead of
a component that failed to load. */
.trail-map-empty {
position: absolute;
inset: auto 0 0 0;
z-index: 500;
padding: 10px 12px;
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
border-top: 1px solid var(--color-border);
font-size: 12.5px;
color: var(--color-ink-3);
text-align: center;
}
/* The divIcon's own box — leaflet gives it a white background and a border by
default, which would frame every pin in a small white square. */
.trail-pin-wrap {
background: none;
border: 0;
}
.trail-pin {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--pin, var(--color-brand));
border: 2px solid #fff;
box-shadow: 0 1px 4px rgb(15 23 42 / 45%);
}
/* A place, not an event. Squared off and a touch larger so a shop reads as the
thing the round starts from rather than as one more drop on it. */
.trail-pin[data-shape='square'] {
width: 15px;
height: 15px;
border-radius: 3px;
}
/* A last-known position, not a live one. Hollow, so the difference between
"here now" and "here when they last reported" is visible on the map itself
and not only in the popup. */
.trail-pin[data-faded='true'] {
background: transparent;
border-color: var(--pin, var(--color-brand));
border-width: 3px;
box-shadow: none;
}
/* Leaflet's controls and popups default to its own font stack and a blue link
colour; both look foreign next to the rest of the console. */
.trail-map .leaflet-container {
font: inherit;
background: var(--color-surface-sunken);
}
/* ── Leaflet Modern Popup Card ────────────────────────────────────────────── */
.trail-map .leaflet-popup {
margin-bottom: 8px;
}
.trail-map .leaflet-popup-content-wrapper {
padding: 0 !important;
border-radius: var(--card-radius) !important;
background: var(--card-bg) !important;
/* The console's hairline, not a near-miss of it. The shadow STAYS: this one
genuinely floats, over a map, which is what a shadow is for. */
border: var(--card-border) !important;
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
overflow: hidden !important;
}
.trail-map .leaflet-popup-content {
margin: 0 !important;
line-height: 1.4 !important;
font-family: inherit !important;
color: #1e293b !important;
}
.trail-map .leaflet-popup-tip-container {
overflow: visible;
}
.trail-map .leaflet-popup-tip {
background: #ffffff !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15) !important;
}
.trail-map .leaflet-popup-close-button {
top: 7px !important;
right: 7px !important;
width: 20px !important;
height: 20px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 50% !important;
background: rgba(241, 245, 249, 0.85) !important;
color: #64748b !important;
font-size: 13px !important;
font-weight: 700 !important;
text-decoration: none !important;
transition: all 0.15s ease !important;
z-index: 10 !important;
padding: 0 !important;
line-height: 1 !important;
}
.trail-map .leaflet-popup-close-button:hover {
background: #fee2e2 !important;
color: #ef4444 !important;
transform: scale(1.08);
}
/* Card Content Structure */
.map-popup-card {
display: flex;
flex-direction: column;
min-width: 220px;
max-width: 275px;
background: #ffffff;
}
.map-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 7px;
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
border-bottom: 1px solid #f1f5f9;
padding-right: 32px;
}
.map-popup-tag {
display: inline-flex;
align-items: center;
gap: 5px;
}
.map-popup-badge-icon {
font-size: 12px;
line-height: 1;
}
.map-popup-id {
font-size: 12.5px;
font-weight: 800;
color: #662582;
letter-spacing: -0.01em;
}
.map-popup-step {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
background: #662582;
color: #ffffff;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.02em;
}
.map-popup-body {
padding: 10px 12px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.map-popup-customer-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.map-popup-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(102, 37, 130, 0.1);
color: #662582;
font-weight: 800;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.map-popup-cust-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.map-popup-customer {
font-size: 13px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.map-popup-subtitle {
font-size: 12px;
font-weight: 700;
color: #334155;
}
.map-popup-address {
font-size: 11px;
color: #64748b;
line-height: 1.35;
white-space: normal;
word-break: break-word;
}
.map-popup-address-box {
display: flex;
align-items: flex-start;
gap: 6px;
background: #f8fafc;
padding: 6px 9px;
border-radius: 8px;
border: 1px solid #e2e8f0;
font-size: 11px;
color: #334155;
line-height: 1.35;
}
.map-popup-status-bar {
display: flex;
align-items: center;
margin-top: 2px;
}
.map-popup-status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--pill-color, #662582) 10%, #ffffff);
border: 1px solid color-mix(in srgb, var(--pill-color, #662582) 25%, transparent);
}
.map-popup-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--pill-color, #662582);
}
.map-popup-status-text {
font-size: 11px;
font-weight: 700;
color: var(--pill-color, #662582);
text-transform: capitalize;
}
.map-popup-time {
font-size: 10px;
font-weight: 600;
color: #64748b;
}
.map-popup-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 12px 8px;
background: #f8fafc;
border-top: 1px solid #f1f5f9;
}
.map-popup-rider {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
font-weight: 600;
color: #334155;
}
.map-popup-rider-icon {
font-size: 12px;
}
.map-popup-amount {
font-size: 12.5px;
font-weight: 800;
color: #047857;
background: rgba(16, 185, 129, 0.1);
padding: 2px 7px;
border-radius: 6px;
letter-spacing: -0.01em;
}
.map-popup-multi-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
padding: 4px 6px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.map-popup-item-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 10.5px;
}
.map-popup-item-id {
font-weight: 700;
color: #662582;
}
.map-popup-item-status {
font-weight: 600;
color: #64748b;
text-transform: capitalize;
}
.map-popup-item-more {
font-size: 10px;
font-weight: 700;
color: #64748b;
text-align: center;
padding-top: 2px;
}
.map-popup-line {
font-size: 12px;
color: #334155;
margin-bottom: 2px;
}
.trail-map .leaflet-control-attribution {
font-size: 10px;
background: color-mix(in oklab, var(--color-surface) 85%, transparent);
}
.trail-map .leaflet-control-attribution a {
color: var(--color-ink-3);
}
/* A numbered pin carries the stop's place in the round. Bigger than a plain
dot because two digits have to stay legible over map detail, and the white
ring is what keeps them readable against a dark tile. */
.trail-pin[data-numbered='true'] {
display: grid;
place-items: center;
width: 20px;
height: 20px;
font-size: 10.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
color: #fff;
text-shadow: 0 1px 1px rgb(15 23 42 / 45%);
}
/* Another round is focused. Faded rather than hidden — a neighbouring rider's
stops are still context, and hiding them makes two overlapping rounds
impossible to compare. */
.trail-pin[data-muted='true'] {
opacity: 0.3;
}
/* The round's first stop. Ringed rather than recoloured, so it keeps its own
status colour while still reading as the start. */
.trail-pin[data-start='true'] {
box-shadow:
0 0 0 2px var(--pin, var(--color-brand)),
0 1px 4px rgb(15 23 42 / 45%);
}

View File

@@ -0,0 +1,104 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from './usePaged';
/**
* `usePaged` is a hook, and there is no React renderer in this suite — the repo
* runs `tsx --test`, not jsdom. So the arithmetic it depends on is written here
* as the pure function the hook applies, and asserted directly.
*
* That is worth doing rather than skipping: every bug this hook exists to
* prevent is an arithmetic one at a boundary — an empty list, a list that
* shrinks under a page, a page size that divides exactly.
*/
interface Slice {
page: number;
totalPages: number;
from: number;
to: number;
rows: number[];
}
/** Mirrors the derivation in `usePaged`, including the clamp. */
function slice(rows: readonly number[], requestedPage: number, pageSize: number): Slice {
const total = rows.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(requestedPage, totalPages);
return {
page,
totalPages,
from: total === 0 ? 0 : (page - 1) * pageSize + 1,
to: Math.min(page * pageSize, total),
rows: rows.slice((page - 1) * pageSize, page * pageSize),
};
}
const upTo = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
test('a full first page', () => {
const out = slice(upTo(96), 1, 25);
assert.equal(out.totalPages, 4);
assert.deepEqual([out.from, out.to], [1, 25]);
assert.equal(out.rows[0], 1);
assert.equal(out.rows.at(-1), 25);
});
test('a middle page counts from the right place', () => {
// The off-by-one everybody writes at least once.
const out = slice(upTo(96), 3, 25);
assert.deepEqual([out.from, out.to], [51, 75]);
assert.equal(out.rows[0], 51);
});
test('the last page is short, and says so', () => {
const out = slice(upTo(96), 4, 25);
assert.deepEqual([out.from, out.to], [76, 96]);
assert.equal(out.rows.length, 21);
});
test('an exact multiple does not produce a trailing empty page', () => {
// 100 rows at 25 is four pages, not five.
assert.equal(slice(upTo(100), 1, 25).totalPages, 4);
});
test('no rows is one page reading 0–0, not zero pages', () => {
/*
`Math.ceil(0 / 25)` is 0, and a totalPages of 0 makes the pager render "page 1
of 0" and every control dead. One empty page is the honest shape.
*/
const out = slice([], 1, 25);
assert.equal(out.totalPages, 1);
assert.deepEqual([out.from, out.to], [0, 0]);
assert.deepEqual(out.rows, []);
});
test('a list that shrinks under you clamps instead of going blank', () => {
/*
The bug this hook exists for. Sitting on page 4, somebody narrows the filter
to nine results. Slicing at the requested page would read rows 76–100 of a
nine-row list and render an empty table with the controls saying page 4 —
which looks exactly like the data vanished.
*/
const out = slice(upTo(9), 4, 25);
assert.equal(out.page, 1, 'clamped to the last page that exists');
assert.equal(out.rows.length, 9);
assert.deepEqual([out.from, out.to], [1, 9]);
});
test('clamping lands on the LAST page, not always the first', () => {
// 60 rows at 25 is three pages; from page 9 you belong on 3, not on 1.
const out = slice(upTo(60), 9, 25);
assert.equal(out.page, 3);
assert.deepEqual([out.from, out.to], [51, 60]);
});
test('every offered page size divides the work sensibly', () => {
// Guards the constants themselves: a 0 or a negative here would make
// totalPages Infinity and hang the pager.
for (const size of PAGE_SIZES) {
assert.ok(size > 0 && Number.isInteger(size), `${size} is a usable page size`);
assert.equal(slice(upTo(100), 1, size).rows.length, Math.min(size, 100));
}
assert.ok(PAGE_SIZES.includes(DEFAULT_PAGE_SIZE), 'the default is one of the choices');
});

131
src/components/usePaged.ts Normal file
View File

@@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from 'react';
/**
* Paging for a table, over rows already in hand.
*
* ── Why client-side ─────────────────────────────────────────────────────────
*
* Fiesta pages properly — `pageno` genuinely shifts the window, verified
* against `getorders`. What it does NOT return is a total: the envelope carries
* `code`, `details`, `message`, `status` and nothing else. So a server-paged
* table could offer next/prev and never honestly say "of 12 pages", and could
* not tell a last page from an empty one until it fetched it.
*
* Every list here already fetches a bounded window (200 rows, 500 for the
* customer book) and renders all of it. Paging that window client-side gives a
* real total, real page numbers, instant page turns, and works identically for
* the tables that have no server paging at all — grouped dispatch stops,
* reports, anything derived. When a table outgrows its fetch window the answer
* is to raise the window or move that ONE table to cursor paging with
* `hasMore`, not to make every table pretend.
*
* ── What this hook is actually for ──────────────────────────────────────────
*
* The slicing is the trivial part. The part worth having in one place, tested,
* is what happens when the rows underneath change — which is where hand-rolled
* paging goes wrong: you filter down to three results while on page 5 and the
* table renders empty with no way back.
*/
export interface Paged<T> {
/** 1-based, matching the design system's Pagination. */
page: number;
setPage: (page: number) => void;
pageSize: number;
setPageSize: (size: number) => void;
/** Just this page's rows. */
rows: T[];
/** Every row, before slicing. */
total: number;
totalPages: number;
/** 1-based inclusive range on screen, for "showing 21–40 of 96". Zero when empty. */
from: number;
to: number;
}
/**
* Fifteen rows, everywhere, until somebody says otherwise.
*
* Every table on the console takes this — no call site overrides it — so this
* constant IS the console's answer to "how long is a page", and changing it
* here changes all of them at once. That is the point: a default that drifts per
* screen makes the pager something to re-read on every page rather than
* something learnt once.
*
* It is a default, not a limit. The selector in `TablePager` offers the sizes
* below and `setPageSize` keeps whatever is chosen for as long as the table is
* mounted, so a person working through a long list can widen it and stay widened.
*/
export const DEFAULT_PAGE_SIZE = 15;
/**
* The choices offered in the page-size selector.
*
* 15 is here because the default has to be selectable — a selector whose current
* value is not one of its options has nothing to show as chosen. 10 stays: it is
* the "show me less" end, and the default moving up is no reason to take it away.
*/
export const PAGE_SIZES = [10, 15, 25, 50, 100];
export function usePaged<T>(
rows: readonly T[],
options: {
pageSize?: number;
/**
* Changing this sends the table back to page 1.
*
* Clamping alone is not enough. Switching branch, day or status tab can
* hand back a DIFFERENT set of rows that happens to be at least as long as
* the old one — nothing to clamp — and the operator is left reading page 4
* of something they just started looking at. Pass whatever identifies the
* query: a day, a branch id, a status, or a template string of several.
*/
resetKey?: string | number;
} = {},
): Paged<T> {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
const total = rows.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const { resetKey } = options;
useEffect(() => {
setPage(1);
}, [resetKey]);
/*
Clamped on the way out as well as reset above.
A row set can shrink under a page without the query changing at all — a
delivery gets marked delivered and leaves the tab, someone types another
letter into the search. Reading `page` directly would then slice past the end
and render an empty table on page 5 of 2, which looks like the data
disappeared. Deriving the safe page rather than setting state in an effect
also means the correct rows render on the FIRST pass, with no empty frame in
between.
*/
const safePage = Math.min(page, totalPages);
const pageRows = useMemo(
() => rows.slice((safePage - 1) * pageSize, safePage * pageSize),
[rows, safePage, pageSize],
);
return {
page: safePage,
setPage,
pageSize,
setPageSize: (size: number) => {
// Back to the first page: keeping the number would land you somewhere
// unrelated, since page 4 of 10-per-page is page 1 of 50-per-page.
setPageSize(size);
setPage(1);
},
rows: pageRows,
total,
totalPages,
from: total === 0 ? 0 : (safePage - 1) * pageSize + 1,
to: Math.min(safePage * pageSize, total),
};
}

View File

@@ -0,0 +1,60 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
/*
The selection logic, lifted out of the hook so it can be tested without React.
The rule that matters: a bulk action must only ever touch rows the person could
see when they chose them. Approving stock moves it, so acting on a row hidden
behind a filter is not a cosmetic bug.
*/
function chosenOf(visible: readonly number[], picked: ReadonlySet<number>) {
return visible.filter((id) => picked.has(id));
}
function afterToggleAll(visible: readonly number[], picked: ReadonlySet<number>) {
const next = new Set(picked);
const everyVisibleChosen = visible.length > 0 && visible.every((id) => next.has(id));
for (const id of visible) {
if (everyVisibleChosen) next.delete(id);
else next.add(id);
}
return next;
}
test('a bulk action never touches a row that was filtered away', () => {
// Ticked while the list showed everything, then the list was narrowed.
const picked = new Set([1, 2, 3]);
assert.deepEqual(chosenOf([2], picked), [2]);
});
test('narrowing and widening again does not untick the work', () => {
// The hidden ids stay in the set; they are simply not acted on while hidden.
const picked = new Set([1, 2, 3]);
assert.deepEqual(chosenOf([1, 2, 3], picked), [1, 2, 3]);
});
test('select all covers only what is on screen', () => {
const next = afterToggleAll([2, 3], new Set());
assert.deepEqual([...next].sort(), [2, 3]);
});
test('select all a second time clears exactly what it added', () => {
const picked = afterToggleAll([2, 3], new Set([9]));
const cleared = afterToggleAll([2, 3], picked);
// 9 was chosen elsewhere and is not on screen, so it survives.
assert.deepEqual([...cleared], [9]);
});
test('select all on an empty list does nothing', () => {
assert.equal(afterToggleAll([], new Set()).size, 0);
});
test('the header is only fully ticked when every visible row is', () => {
const visible = [1, 2];
const partial = chosenOf(visible, new Set([1]));
assert.equal(partial.length === visible.length, false);
const full = chosenOf(visible, new Set([1, 2]));
assert.equal(full.length === visible.length, true);
});

View File

@@ -0,0 +1,88 @@
import { useCallback, useMemo, useState } from 'react';
/**
* Which rows a person has ticked, and the header checkbox that follows.
*
* Shared by the three screens that grew a bulk action — importing from the
* catalogue, requesting stock, and deciding requests — because the fiddly parts
* are the same every time and getting them subtly different between screens is
* how a merchant learns to distrust the tick boxes.
*
* ── The part that is easy to get wrong ──────────────────────────────────────
*
* A selection is kept against the ROWS CURRENTLY VISIBLE. Filter a list down,
* tick everything, clear the filter, and press the button: a naive
* implementation acts on rows the person could not see when they chose. So
* "select all" only ever covers what is on screen, and `chosen` is intersected
* with the visible ids before it is handed back.
*
* Ids that scroll out of view are NOT dropped from the set, because narrowing a
* search and widening it again should not silently untick the work. They are
* simply not acted on while they are hidden.
*/
export interface Selection {
/** Visible ids that are ticked — what a bulk action should act on. */
chosen: number[];
count: number;
has: (id: number) => boolean;
toggle: (id: number) => void;
/** Tick or untick everything currently visible. */
toggleAll: () => void;
clear: () => void;
/** Every visible row is ticked. Drives the header checkbox. */
allChosen: boolean;
/** Some but not all — the indeterminate state. */
someChosen: boolean;
}
export function useSelection(visibleIds: readonly number[]): Selection {
const [picked, setPicked] = useState<ReadonlySet<number>>(() => new Set());
const chosen = useMemo(
() => visibleIds.filter((id) => picked.has(id)),
[visibleIds, picked],
);
const toggle = useCallback((id: number) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const allChosen = visibleIds.length > 0 && chosen.length === visibleIds.length;
const toggleAll = useCallback(() => {
setPicked((prev) => {
const next = new Set(prev);
const everyVisibleChosen =
visibleIds.length > 0 && visibleIds.every((id) => next.has(id));
for (const id of visibleIds) {
if (everyVisibleChosen) {
next.delete(id);
} else {
next.add(id);
}
}
return next;
});
}, [visibleIds]);
const clear = useCallback(() => setPicked(new Set()), []);
return {
chosen,
count: chosen.length,
has: (id: number) => picked.has(id),
toggle,
toggleAll,
clear,
allChosen,
someChosen: chosen.length > 0 && !allChosen,
};
}

9
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
/**
* When this bundle was built, injected by `vite.config.ts`.
*
* Shown at the foot of the account menu so a stale copy is visible on the
* screen rather than only in someone's memory of what was delivered.
*/
declare const __BUILD_STAMP__: string;

View File

@@ -0,0 +1,946 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import {
AlertCircle,
ArrowRight,
Eye,
EyeOff,
Loader2,
Lock,
Mail,
Building2,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import {
checkAccount,
MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError,
WrongConsoleError,
setInitialPassword,
} from '@/auth/session';
/**
* Sign-in — KROW's full-bleed auth archetype.
*
* A single 1024px card at 28px radius over the ambient canvas, split two-up:
* a tinted brand panel on the left carrying an inset product image, and the
* form on the right at a 448px measure. Rhythm is 24px between regions, 16px
* between fields, 6px inside a field.
*
* One form for all three roles. The old console had three separate login paths
* because the backend compares a client-declared roleid against the stored one;
* here the role is read off the login response, so a person signs in once and
* lands where their account says they belong.
*/
export function LoginPage() {
const { user, signIn } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
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. */
const [notice, setNotice] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
/**
* Which of the three 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.
*
* 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.
*/
const [step, setStep] = useState<'email' | 'password' | 'setup'>('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 />;
/** Step one: which door does this email need? */
async function handleEmail(event: FormEvent) {
event.preventDefault();
setError(null);
setIsBusy(true);
try {
const check = await checkAccount(email);
if (check.state === 'setup') {
setSetupUserid(check.userid);
setNewPassword('');
setConfirmPassword('');
setStep('setup');
} else {
setStep('password');
}
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Could not check that email');
} finally {
setIsBusy(false);
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setIsBusy(true);
try {
const session = await signIn(email, password);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
// Still handled, even though the probe should have caught it. Between the
// 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');
} 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');
}
} finally {
setIsBusy(false);
}
}
/** Back to the email field, from either of the two second steps. */
function restart() {
setStep('email');
setSetupUserid(null);
setPassword('');
setNewPassword('');
setConfirmPassword('');
setError(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);
}
}
const canSubmit =
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
const canSetup =
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
return (
<div
className="login-shell"
style={{ minHeight: '100dvh', display: 'grid', placeItems: 'center' }}
>
<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(--card-bg)',
border: 'var(--card-border)',
borderRadius: 'var(--card-radius)',
boxShadow: 'var(--card-shadow)',
overflow: 'hidden',
}}
>
<BrandPanel />
{/* 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 }}>
{step !== 'setup' ? (
<FormPanel
step={step}
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
error={error}
notice={notice}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
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>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Left — the brand panel
──────────────────────────────────────────────────────────────────────────── */
/**
* A tint, not a slab.
*
* The reference panel is a soft wash rather than a saturated block, so the card
* reads as one surface with a warm side rather than two glued halves. The
* gradient runs from the ambient canvas's violet stop into its warm stop, which
* is what ties the card to the page behind it.
*/
function BrandPanel() {
return (
<div
className="login-brand"
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: 'linear-gradient(155deg, #F4EEF8 0%, #F7F3F9 45%, #FBF8F2 100%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img
src="/logo-wordmark.png"
alt="Nearle"
style={{ height: 28, width: 'auto', display: 'block' }}
/>
<span
style={{
background: 'var(--color-brand)',
color: '#fff',
borderRadius: 999,
padding: '3px 10px',
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.1em',
}}
>
CONSOLE
</span>
</div>
{/* The inset image card: white, 8px inset, hairline border, soft shadow.
Hidden on a phone, where it would push the form below the fold. */}
<div
className="login-hero"
style={{
alignSelf: 'center',
maxWidth: 320,
width: '100%',
background: '#fff',
border: '1px solid var(--color-line)',
borderRadius: 20,
padding: 8,
boxShadow: '0 4px 16px -2px rgb(16 24 32 / .06), 0 2px 6px -3px rgb(16 24 32 / .04)',
}}
>
<div
style={{
borderRadius: 14,
overflow: 'hidden',
background: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
aspectRatio: '1',
}}
>
<img
src="/logo-512.png"
alt=""
width={280}
height={280}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
</div>
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 7,
color: 'var(--color-brand)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.11em',
textTransform: 'uppercase',
marginBottom: 10,
}}
>
<Sparkles size={13} />
Retail operations platform
</div>
<h2
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 24,
lineHeight: 1.28,
fontWeight: 600,
letterSpacing: '-0.015em',
color: 'var(--color-ink-1)',
}}
>
Every store, every till, one console.
</h2>
<p
style={{
margin: '10px 0 0',
fontSize: 13.5,
lineHeight: 1.65,
color: 'var(--color-ink-3)',
maxWidth: 380,
}}
>
Onboard tenants and branches, publish the catalogue, and watch online orders and counter
sales land side by side.
</p>
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Right — the form
──────────────────────────────────────────────────────────────────────────── */
interface FormPanelProps {
step: 'email' | 'password';
onBack: () => void;
email: string;
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;
onPassword: (value: string) => void;
onToggleVisible: () => void;
onSubmit: (event: FormEvent) => void;
}
function FormPanel({
step,
onBack,
email,
password,
isPasswordVisible,
error,
notice,
isBusy,
canSubmit,
onEmail,
onPassword,
onToggleVisible,
onSubmit,
}: FormPanelProps) {
return (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
<div style={{ width: '100%', maxWidth: 448, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<h1
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 26,
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
Welcome back
</h1>
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{step === 'email'
? 'Sign in to manage tenants, branches, catalogue and counter sales.'
: `Signing in as ${email}.`}
</p>
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field label="Work email" htmlFor="email" icon={<Mail size={15} />}>
<input
id="email"
type="email"
value={email}
onChange={(event) => onEmail(event.target.value)}
placeholder="you@company.com"
autoComplete="username"
autoFocus={step === 'email'}
required
readOnly={step === 'password'}
style={{
...inputStyle,
...(step === 'password'
? { color: 'var(--color-ink-3)', cursor: 'default' }
: {}),
}}
/>
</Field>
{step === 'password' ? (
<Field
label="Password"
htmlFor="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="password"
type={isPasswordVisible ? 'text' : 'password'}
value={password}
onChange={(event) => onPassword(event.target.value)}
placeholder="••••••••"
autoComplete="current-password"
autoFocus
required
style={{ ...inputStyle, paddingRight: 40 }}
/>
</Field>
) : null}
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
The checkbox was initialised true, toggled, rendered — and never
read: `handleSubmit` and `signIn(email, password)` never received
it, so ticking or clearing it changed nothing about how long the
session lasted. The link had no handler at all, and there is no
password-reset endpoint in Fiesta to give it one. Both promised
something the system does not do. */}
<ErrorNote message={error} />
<ConsoleNote message={notice} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel={step === 'email' ? 'Checking…' : 'Signing in…'}
label={step === 'email' ? 'Continue' : 'Sign in'}
/>
</form>
{step === 'password' ? (
<button type="button" onClick={onBack} style={backLinkStyle}>
Use a different account
</button>
) : null}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.6,
color: 'var(--color-ink-4)',
textAlign: 'center',
}}
>
Use the account your administrator set up for you. Your role decides which workspace
opens — you do not pick one.
</p>
</div>
</div>
);
}
/* ────────────────────────────────────────────────────────────────────────────
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>
{/* No ConsoleNote here. The setup step is reached only by an account
that has never had a password — a branch login this console just
spawned — so it is the right console by construction. */}
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button type="button" onClick={onBack} style={backLinkStyle}>
Use a different account
</button>
</div>
</div>
);
}
/** A quiet line under the fields — advisory, never an error. */
function Hint({ children }: { children: ReactNode }) {
return (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
{children}
</p>
);
}
/* ────────────────────────────────────────────────────────────────────────────
Shared form furniture
──────────────────────────────────────────────────────────────────────────── */
function ErrorNote({ message }: { message: string | null }) {
if (!message) return null;
return (
<div
role="alert"
style={{
display: 'flex',
alignItems: 'flex-start',
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,
}}
>
<AlertCircle size={16} style={{ flex: 'none', marginTop: 1 }} />
{message}
</div>
);
}
/**
* 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,
label,
busyLabel,
}: {
canSubmit: boolean;
isBusy: boolean;
label: string;
busyLabel: string;
}) {
return (
<button
type="submit"
disabled={!canSubmit}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 9,
height: 46,
width: '100%',
borderRadius: 14,
border: 0,
background: canSubmit
? 'var(--color-brand)'
: 'color-mix(in oklab, var(--color-brand) 45%, #fff)',
color: '#fff',
fontSize: 14.5,
fontWeight: 600,
cursor: canSubmit ? 'pointer' : 'default',
boxShadow: canSubmit ? '0 10px 30px -6px rgb(102 37 130 / .28)' : 'none',
transition: 'background .2s cubic-bezier(.16,1,.3,1), box-shadow .2s',
}}
>
{isBusy ? (
<>
<Loader2 size={16} style={{ animation: 'spin 1s linear infinite' }} />
{busyLabel}
</>
) : (
<>
{label}
<ArrowRight size={16} />
</>
)}
</button>
);
}
const backLinkStyle: React.CSSProperties = {
alignSelf: 'center',
border: 0,
background: 'transparent',
padding: 0,
fontSize: 12.5,
fontFamily: 'inherit',
color: 'var(--color-ink-3)',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 3,
};
const eyeButtonStyle: React.CSSProperties = {
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
width: 28,
height: 28,
display: 'grid',
placeItems: 'center',
borderRadius: 8,
border: 0,
background: 'transparent',
color: 'var(--color-ink-4)',
cursor: 'pointer',
};
/* ────────────────────────────────────────────────────────────────────────────
Field
──────────────────────────────────────────────────────────────────────────── */
const inputStyle: React.CSSProperties = {
height: 44,
width: '100%',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
padding: '0 12px 0 38px',
fontSize: 14,
fontFamily: 'inherit',
color: 'var(--color-ink-1)',
outline: 'none',
transition: 'border-color .2s, background .2s, box-shadow .2s',
};
/**
* A labelled field.
*
* The label is a small-caps overline rather than sentence case — the auth page
* is the one screen with only two inputs on it, and the extra weight there
* reads as deliberate rather than shouty.
*/
function Field({
label,
htmlFor,
icon,
action,
children,
}: {
label: string;
htmlFor: string;
icon: ReactNode;
action?: ReactNode;
children: ReactNode;
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label
htmlFor={htmlFor}
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: 'var(--color-ink-3)',
}}
>
{label}
</label>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<span
style={{
position: 'absolute',
left: 13,
display: 'flex',
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
>
{icon}
</span>
{children}
{action}
</div>
</div>
);
}

View File

@@ -0,0 +1,603 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
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 { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, SearchX } from 'lucide-react';
import { SearchInput } from '@/components/SearchInput';
import { catalogueKey, catalogueKeysOf } from '@/api/catalogue';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { APP_BROWSE_CATEGORY } from './tenantCategories';
import { aisleIdForCategory, aisleIdsFrom } from '@/features/store-admin/appAisle';
import { useSelection } from '@/components/useSelection';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import {
useCatalogueBrands,
useCatalogueCategories,
useCatalogueProducts,
useImportedRefs,
} from '@/queries/hooks';
import { CatalogueCard } from './CatalogueCard';
import { CatalogueSidebar } from './CatalogueSidebar';
import { CatalogueDetailDrawer } from './CatalogueDetailDrawer';
const PAGE_SIZE = 24;
export interface CatalogueBrowserProps {
/** The merchant being stocked. Without one nothing can be imported. */
tenantid: number | undefined;
/** The outlet the import is written against. Required by the backend. */
locationid: number | undefined;
/** Wording on the card and drawer buttons. */
actionLabel: string;
/**
* Import through the caller instead of writing here.
*
* The Nearle Admin collects a price, a cost and a tax rate first, so its
* import is a form rather than a click; the Store Admin's is one call. The
* grid is the same either way, which is the point of this component.
*/
onImport?: (product: CatalogueProduct) => void;
/** Shown above the filters — the tenant picker, the mode toggle, a banner. */
scope?: ReactNode;
/**
* Browsing only — no Add on the cards, none in the drawer.
*
* The Nearle Admin uses this: a platform operator reads the catalogue to
* check what is in it, and adds products through the spreadsheet upload
* rather than one at a time into somebody else's shop.
*/
isReadOnly?: boolean;
/** Why importing is unavailable, if it is. */
blockedReason?: string;
}
/**
* The global catalogue browser, shared by both consoles.
*
* There were two of these — a 920px drawer in the Store Admin and a page in the
* Nearle Admin — with different cards, different filters, different empty
* states and different words for the same button. Same data, same job, two
* designs that had already drifted apart in a month. This is the one.
*
* What differs between the two workspaces is genuinely different and stays a
* prop: who is being stocked (`scope`), and what happens on import. Everything
* a person looks at is shared.
*
* Built on Astryx primitives throughout — `ClickableCard`, `Token`,
* `EmptyState`, `Skeleton` — rather than the inline styles that grew here
* first, so the catalogue reads as part of the console instead of a page that
* happens to sit inside it.
*/
export function CatalogueBrowser({
tenantid,
locationid,
actionLabel,
onImport,
scope,
blockedReason,
isReadOnly,
}: CatalogueBrowserProps) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
const [open, setOpen] = useState<CatalogueProduct | null>(null);
const [category, setCategory] = useState('');
const [page, setPage] = useState(1);
/**
* Whether the brand rail is showing.
*
* Open by default: the rail is how you get anywhere in a catalogue of
* thousands, and starting it closed would hide the navigation behind an icon
* on a page whose whole job is browsing. The toggle is for the moment you
* have chosen a brand and want the width back for the photographs.
*/
const [isFiltersOpen, setIsFiltersOpen] = useState(true);
/**
* 400ms, and it costs more than it looks to get wrong.
*
* The all-brands search path pulls every brand's filtered set into Go memory
* and slices there (`catalogueRepository.go:321-374`), so a request per
* keystroke is expensive on the server, not merely chatty.
*/
useEffect(() => {
const id = setTimeout(() => setDebounced(keyword.trim()), 400);
return () => clearTimeout(id);
}, [keyword]);
// Any change to what is being asked for starts at page one. Staying on page
// 7 of a search that now has two results shows an empty grid and reads as a
// broken filter.
useEffect(() => {
setPage(1);
}, [brand, category, debounced]);
// A category belongs to one brand's table; carrying it across brands filters
// on a name the new brand has never heard of and quietly returns nothing.
useEffect(() => {
setCategory('');
}, [brand]);
const brands = useCatalogueBrands();
const categories = useCatalogueCategories(brand || undefined);
const imported = useImportedRefs(tenantid);
const search = useCatalogueProducts({
...(brand ? { brand } : {}),
...(category ? { category } : {}),
...(debounced ? { keyword: debounced } : {}),
// 1-based, and not optional: `pageno <= 0` is clamped to 1 server-side
// (`catalogueRepository.go:272`), so a 0-based page would fetch page one
// twice and show it as page two.
pageno: page,
pagesize: PAGE_SIZE,
});
const rows = search.data ?? [];
/**
* How many pages there are, when we can know.
*
* `getbrands` reports a count per brand, so a brand-filtered view has a real
* total. Everything else — all brands, or any keyword — has none, because
* `api.list` returns the rows and drops the envelope's `total`. There,
* `hasMore` from a full page is the honest answer, and Pagination renders
* prev/next instead of inventing a last page.
*/
const brandTotal = brand
? (brands.data ?? []).find((entry) => entry.brand === brand)?.product_count
: undefined;
const knownTotal = !category && !debounced ? brandTotal : undefined;
const importedKeys = useMemo(() => {
// Both keys per ref, not one. During the changeover a ref can carry the
// stable key it has just acquired AND the id it was imported under, and a
// product that has not been relinked yet is still genuinely imported.
const set = new Set((imported.data ?? []).flatMap(catalogueKeysOf));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
/**
* Which rows are ticked for a bulk import.
*
* Only rows that are NOT already imported can be selected. A product already
* on the shelf has nothing to do, and letting it be ticked would put it in
* the count on the button — "Add 12" that adds nine is worse than no count.
*/
const selectableIds = useMemo(
() => rows.filter((product) => !importedKeys.has(catalogueKey(product))).map((product) => product.id),
[rows, importedKeys],
);
const selection = useSelection(selectableIds);
const importMany = useMutation({
/*
Every distinct category in the batch resolved in ONE call, then each row
built with its own id.
Not `products.map(importRowFor)`: `map` hands the callback the array
INDEX as its second argument, which is a number, so passing the row
builder directly type-checks perfectly and files the first product under
category 0, the second under 1, and so on. It was written that way for a
one-argument builder and stayed valid the moment the second argument
arrived.
*/
mutationFn: async (products: CatalogueProduct[]) => {
const ids = await aisleIds();
return productsApi.importFromCatalogue(
products.map((product) =>
importRowFor(product, aisleIdForCategory(categoryNameFor(product), ids)),
),
);
},
onSuccess: async (_result, products) => {
// Ticked locally as well as refetched: the imported list is a separate
// query and the grid would otherwise show them as un-imported until it
// came back, tempting a second click.
setJustImported((set) => {
const next = new Set(set);
for (const product of products) next.add(catalogueKey(product));
return next;
});
selection.clear();
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
client.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
},
});
const importOne = useMutation({
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
client.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
},
});
/**
* One import row.
*
* Extracted so the single-product button and the bulk action build the SAME
* row. Two copies of this object is how a bulk import quietly writes a
* different category, or a price where the single one writes none.
*/
/**
* The category id an import should use for one catalogue product.
*
* The catalogue already knows what this is — "Spices & Masalas" sits on the
* row — and that answer comes from the same deterministic ladder the
* pipeline runs. Discarding it in favour of whatever single category the
* tenant happened to have is how an Aachi masala arrived filed under
* "Category 2".
*
* An EXPLICIT choice still wins, which is the ladder's own first rule: if the
* operator has touched the picker, that is their answer and it is kept. Until
* they do, `importInto` is null and the catalogue's own category is used.
*/
/**
* The same decision for a whole batch, in one round trip.
*
* A request per product would open one connection per row from a shop's
* browser — the mistake the catalogue reconciliation already documents.
*/
/**
* The category NAME for one catalogue product, always one of the 31.
*
* The catalogue's own value is used when it is canonical; when it is not —
* "Food - Mixes", "Pickles & Chutneys", "Dairy - Desserts" and the rest,
* about a third of the rows sampled — the product is classified by the ladder
* instead, so nothing is filed under a name the published list does not have.
*/
function categoryNameFor(product: CatalogueProduct): string {
return categoryForCatalogueProduct({
catalogueCategory: product.category,
title: product.product_name ?? '',
description: product.description ?? '',
packSize: product.size ?? '',
}).category;
}
/**
* The aisle ids the app groups by, read from the platform's own list.
*
* One call for a whole batch. It is `productsubcategories` for category 2 —
* ten rows, shared by every tenant — and matching on the NAME rather than
* trusting a remembered id is what keeps this off the app's other
* subcategory table, which carries the same ten names five ids lower.
*/
async function aisleIds(): Promise<Map<string, number>> {
try {
return aisleIdsFrom(await productsApi.subCategories(tenantid as number, APP_BROWSE_CATEGORY));
} catch {
return aisleIdsFrom(undefined);
}
}
function importRowFor(
product: CatalogueProduct,
subcategoryid: number,
): ImportCatalogueProductRequest {
return {
tenantid: tenantid as number,
locationid: locationid as number,
brand: product.brand,
catalogueid: product.id,
/* ALWAYS 2, and this is not a placeholder.
`getproductsbysubcategory` filters on `categoryid = 2` — the value the
app sends — so a per-product categoryid does not label the product, it
deletes it from the app's view. What the shopper actually reads as the
aisle heading is the subcategory below, which until now was 0 on every
product on the platform: one bucket, "Uncategorized", holding the shop.
See `appAisle.ts` for the endpoint this is measured against. */
categoryid: APP_BROWSE_CATEGORY,
subcategoryid,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing.
retailprice: 0,
productcost: 0,
taxpercent: 0,
};
}
async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return;
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync(
importRowFor(product, aisleIdForCategory(categoryNameFor(product), await aisleIds())),
);
setJustImported((set) => new Set(set).add(key));
} finally {
setBusy(null);
}
}
const run = onImport ?? ((product: CatalogueProduct) => void importDirect(product));
const canImport = Boolean(!isReadOnly && tenantid && locationid && !blockedReason);
function clearFilters() {
setBrand('');
setCategory('');
setKeyword('');
}
const activeFilters = [
...(brand ? [{ key: 'brand', label: brand.replace('brand_', ''), clear: () => setBrand('') }] : []),
...(category ? [{ key: 'category', label: category, clear: () => setCategory('') }] : []),
...(debounced
? [{ key: 'keyword', label: `“${debounced}”`, clear: () => setKeyword('') }]
: []),
];
return (
<VStack gap={2}>
{scope}
{/* Search, filters and the rail toggle, on their own line.
They used to be PULLED UP onto the page's tab row by a -42px offset
(`alignWithTabs`, `.catalogue-controls`), because that row looked
mostly empty to the right. It is not empty any more — Inventory's tab
row carries "Upload sheet" at its right end — and the offset does not
move a narrow control into a gap, it lays a FULL-WIDTH row on top of
the whole tab row: measured at 24 → 1090px, covering all four tabs and
the button. Clicks landed on this element and nothing switched tabs,
which read as "the tabs stop working once you open the Catalogue".
That was the second time. The prop's own note recorded it happening to
the platform catalogue's mode toggle, and it was fixed there by not
passing the prop — leaving the offset in place to catch the next row
that grew an action. Two controls cannot share one right-hand corner,
so search keeps its own line and the tab row keeps its button.
It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */}
{/* 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}
</HStack>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
{isFiltersOpen ? (
<CatalogueSidebar
brands={brands.data ?? []}
isLoading={brands.isLoading}
brand={brand}
category={category}
/* 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}
/>
) : null}
<VStack gap={2}>
{/* No result-count line. It said "24 products on this page", which is
the page size restated — a number that never changes and answers a
question nobody asked. Where the page belongs in the whole is what
the pagination at the foot is for, and it says it there. */}
{blockedReason ? (
<Text type="body" size="xsm" style={{ color: 'var(--color-warning, #b7860b)' }}>
{blockedReason}
</Text>
) : null}
{search.isLoading ? (
<div className="product-grid">
{Array.from({ length: 8 }, (_, index) => (
<Skeleton key={index} height={320} radius={3} index={index} />
))}
</div>
) : search.isError ? (
<EmptyState
icon={<SearchX size={28} />}
title="The catalogue did not answer"
description="The request failed rather than came back empty. Try again, and if it keeps failing the catalogue service is the thing to look at."
actions={
<Button label="Try again" variant="secondary" onClick={() => void search.refetch()} />
}
/>
) : rows.length === 0 ? (
<EmptyState
icon={<PackageSearch size={28} />}
title="Nothing matches those filters"
description={
activeFilters.length > 0
? 'Clear a filter to widen the search — a brand, a category and a keyword together often narrow the catalogue to nothing.'
: 'The catalogue returned no products at all, which usually means the brand tables have not been populated.'
}
{...(activeFilters.length > 0
? {
actions: (
<Button label="Clear filters" variant="secondary" onClick={clearFilters} />
),
}
: {})}
/>
) : (
<>
{/* Bulk import. Hidden entirely when there is nothing to import
into — a merchant with no outlet selected is already told why
by `blockedReason`, and a second dead control below it adds
nothing. */}
{canImport && !onImport && selectableIds.length > 0 ? (
<div className="bulkbar" data-active={selection.count > 0 ? 'yes' : 'no'}>
<HStack justify="between" align="center" gap={2} wrap="wrap">
<label className="bulkbar-all">
<input
type="checkbox"
checked={selection.allChosen}
ref={(el) => {
if (el) el.indeterminate = selection.someChosen;
}}
onChange={selection.toggleAll}
aria-label={
selection.allChosen ? 'Clear selection' : 'Select every product on this page'
}
/>
<Text type="body" size="sm">
{selection.count > 0
? `${selection.count} selected`
: `Select all ${selectableIds.length} on this page`}
</Text>
</label>
{selection.count > 0 ? (
<HStack gap={1} align="center" wrap="wrap">
<Button
label="Clear"
variant="ghost"
size="sm"
isDisabled={importMany.isPending}
onClick={selection.clear}
/>
<Button
label={
importMany.isPending
? 'Adding…'
: `${actionLabel} (${selection.count})`
}
variant="primary"
size="sm"
isLoading={importMany.isPending}
isDisabled={importMany.isPending}
onClick={() =>
importMany.mutate(
rows.filter((product) => selection.has(product.id)),
)
}
/>
</HStack>
) : null}
</HStack>
</div>
) : null}
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
return (
<CatalogueCard
key={key}
product={product}
isImported={isImported}
isBusy={busy === key || importMany.isPending}
isDisabled={!canImport}
actionLabel={actionLabel}
onOpen={() => setOpen(product)}
{...(canImport && !onImport && !isImported
? {
isSelected: selection.has(product.id),
onSelect: () => selection.toggle(product.id),
}
: {})}
{...(isReadOnly ? {} : { onImport: () => run(product) })}
/>
);
})}
</div>
{/* With a known total this renders numbered pages; without one it
falls back to prev/next off `hasMore`, which is all a response
that carries no count can honestly support. */}
<HStack justify="center">
<Pagination
page={page}
onChange={setPage}
pageSize={PAGE_SIZE}
{...(knownTotal
? { totalItems: knownTotal }
: { hasMore: rows.length === PAGE_SIZE })}
/>
</HStack>
</>
)}
</VStack>
</div>
{open ? (
<CatalogueDetailDrawer
product={open}
isImported={importedKeys.has(catalogueKey(open))}
isBusy={busy === catalogueKey(open)}
actionLabel={actionLabel}
{...(canImport && !isReadOnly
? {
onImport: () => {
const product = open;
if (onImport) setOpen(null);
run(product);
},
}
: {})}
{...(blockedReason
? // The only reason left is the caller's — "no outlet selected".
// "This merchant has no category yet" used to sit here too and is
// gone: the category is derived from the product and created on
// demand, so there is no longer a state where a merchant has
// nowhere to file something.
{ blockedReason }
: {})}
onClose={() => setOpen(null)}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,251 @@
import { useState, type MouseEvent } from 'react';
import { Check, ChevronLeft, ChevronRight, Eye, ImageOff, Plus } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
/**
* One catalogue product, as a card.
*
* The proportions are taken from the reference storefront: a tall white media
* panel, a badge pinned top-left, an action rail that fades in on the right,
* then name, meta and one full-width button. Three things in that reference are
* deliberately NOT reproduced, because we have no data behind them and a card
* that states a figure it invented is worse than a card that says less:
*
* - **The discount flash** ("10% / 20%"). The catalogue carries a price RANGE
* across outlets, not a was-and-now. There is no discount to show. The brand
* takes that corner instead, which is the fact a person actually sorts by.
* - **The star rating.** No review data exists anywhere in Fiesta.
* - **The struck-through original price.** Same reason as the flash.
*
* And the quantity stepper is gone with them: importing does not move stock.
* It puts an unpriced product in the admin catalogue, so there is no quantity
* to choose at this moment — that happens at the stock request.
*/
export function CatalogueCard({
product,
isImported,
isBusy,
isDisabled,
actionLabel,
onOpen,
onImport,
isSelected,
onSelect,
}: {
product: CatalogueProduct;
isImported: boolean;
isBusy?: boolean;
isDisabled?: boolean;
actionLabel: string;
onOpen: () => void;
onImport?: () => void;
/**
* Present only when this card can take part in a bulk import. Absent for a
* product already on the shelf, and absent entirely on read-only views — so
* the tick box appears exactly where it does something.
*/
isSelected?: boolean;
onSelect?: () => void;
}) {
const images = product.images ?? [];
const [index, setIndex] = useState(0);
/**
* Which photos failed to load, by position.
*
* A single flag for the whole card was wrong: one dead URL blanked the card
* even when the product had four working photos behind it, and the flag
* only cleared if somebody happened to press an arrow. Catalogue images are
* scraped, and a retired or renamed object in the bucket is ordinary — a
* card should fall through to the next photo, not give up.
*/
const [failed, setFailed] = useState<ReadonlySet<number>>(new Set());
const photos = images.length;
/** The first photo from `index` onwards that has not failed, wrapping. */
const shownAt = (() => {
for (let hop = 0; hop < photos; hop += 1) {
const at = (index + hop) % photos;
if (!failed.has(at)) return at;
}
return -1;
})();
const image = shownAt >= 0 ? images[shownAt] : undefined;
const hasMany = photos > 1;
const markFailed = (at: number) =>
setFailed((current) => {
const next = new Set(current);
next.add(at);
return next;
});
/** Wrap in both directions, and never let the card open behind the arrow. */
const step = (event: MouseEvent<HTMLElement>, by: number) => {
event.stopPropagation();
setIndex((current) => (current + by + photos) % photos);
};
return (
<article className="pcard" data-imported={isImported || undefined}>
{/*
A div with an overlay button inside it, not a button around everything.
The arrows and dots are real buttons and a button cannot legally contain
another — nesting them produces a tree browsers repair by hoisting the
inner one out, which is how a photo arrow ends up opening the drawer.
So the click target is its own transparent layer underneath the
controls.
*/}
<div className="pcard-media">
{onSelect ? (
<label
className="pcard-tick"
/* The card body is a click target that opens the drawer. Ticking
must not also open it, so the label swallows the event before it
reaches the overlay underneath. */
onClick={(event) => event.stopPropagation()}
>
<input
type="checkbox"
checked={Boolean(isSelected)}
onChange={onSelect}
aria-label={`Select ${product.product_name}`}
/>
</label>
) : null}
{image ? (
<img
// Keyed by the URL so a fall-through to the next photo actually
// remounts the element — React would otherwise keep the failed
// image node and never re-attempt the load.
key={image}
src={image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => markFailed(shownAt)}
/>
) : (
<span className="pcard-noimage" title={photos > 0 ? `${photos} photos, none loaded` : 'No photo'}>
<ImageOff size={30} />
</span>
)}
<button
type="button"
className="pcard-open"
onClick={onOpen}
aria-label={`View ${product.product_name}`}
/>
{/* No brand flag. The reference puts a discount there and we have no
discount to show; a brand chip in its place restated what the rail
on the left already says, what the SKU under the name repeats, and
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
real catalogue's rows carry several shots that the browse card
otherwise never shows. */}
{hasMany ? (
<>
<button
type="button"
className="pcard-arrow is-left"
aria-label="Previous photo"
onClick={(event) => step(event, -1)}
>
<ChevronLeft size={15} />
</button>
<button
type="button"
className="pcard-arrow is-right"
aria-label="Next photo"
onClick={(event) => step(event, 1)}
>
<ChevronRight size={15} />
</button>
<span className="pcard-dots">
{images.slice(0, 8).map((src, position) => (
<button
type="button"
// Position, not the URL: a catalogue row can list the same
// photo twice, and React drops a duplicate key — which reads
// as a dot row shorter than the count it stands for.
key={`${position}-${src}`}
className="pcard-dot"
data-active={position === shownAt || undefined}
aria-label={`Photo ${position + 1}`}
onClick={(event) => {
event.stopPropagation();
setIndex(position);
}}
/>
))}
</span>
</>
) : null}
</div>
<div className="pcard-body">
{/* The reference puts a star rating here. There is no review data
anywhere in Fiesta, so the line carries what this catalogue does
know about the pack — its code and its size. */}
<p className="pcard-meta">
<span className="pcard-sku">{product.product_sku ?? `id ${product.id}`}</span>
{product.size ? <span className="pcard-size">{product.size}</span> : null}
{photos > 1 ? <span className="pcard-photos">{photos} photos</span> : null}
</p>
<button type="button" className="pcard-name" onClick={onOpen} title={product.product_name}>
{product.product_name}
</button>
{/* Price and action on ONE row, as in the reference. A full-width button
under every card made the action the loudest thing in a grid of
twenty-four; beside the price it is available without insisting. */}
<div className="pcard-foot">
{/* A RANGE — what other outlets list it at, not a price this store is
bound to. The store sets its own after the import. */}
<span className="pcard-price">{product.price_range || '—'}</span>
{onImport ? (
isImported ? (
<span className="pcard-action is-added">
<Check size={12} /> Added
</span>
) : (
/* "Add", not the full instruction.
The reference says "Add to cart" — three short words beside a
price. "Add to my products" is eighteen characters and wrapped
the price onto two lines at this card width. The full wording
stays as the accessible name, and the drawer says it in full at
the point where somebody is deciding rather than scanning. */
<button
type="button"
className="pcard-action"
title={actionLabel}
aria-label={actionLabel}
disabled={isDisabled || isBusy}
onClick={onImport}
>
<Plus size={12} /> {isBusy ? 'Adding…' : 'Add'}
</button>
)
) : null}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,312 @@
import { useEffect, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Lightbox } from '@astryxdesign/core/Lightbox';
import { Check, DownloadCloud, ImageOff } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
import {
Badge,
Bullets,
DrawerButton,
DrawerCard,
Metric,
Metrics,
Mono,
Row,
Section,
} from '@/features/store-admin/drawerKit';
import { categoryForCatalogueProduct } from '@/features/store-admin/productCategory';
import { aisleForCategory } from '@/features/store-admin/appAisle';
import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel';
/**
* One global-catalogue product, in full.
*
* Ported from the old console's `ImportProductModal`, which was the only screen
* in the platform that showed what the catalogue actually holds. The API
* returns nineteen fields per row and a browse card renders five; the rest —
* description, highlights, nutrition, the FSSAI licence, the provider list,
* every photo beyond the first — arrived in every response and had nowhere to
* be read.
*
* ONE THING FROM THE OLD SCREEN IS DELIBERATELY NOT HERE: its "Retail Packaging
* Info" panel. That block is `FMCGHoverOverlay`, which derives its contents from
* `simpleHash(productId)` and a keyword match on the category — the shelf life,
* the storage advice and the packaging notes it prints are generated from the
* product's id, not read from anywhere. It reads as compliance information and
* is invented, so it stays out.
*/
export interface CatalogueDetailDrawerProps {
product: CatalogueProduct;
/** Already in this tenant's catalogue — the import action becomes a note. */
isImported: boolean;
isBusy?: boolean;
actionLabel?: string;
/** Absent when the caller has nowhere to import to yet. */
onImport?: () => void;
/** Shown in place of the action when importing is unavailable. */
blockedReason?: string;
onClose: () => void;
}
export function CatalogueDetailDrawer({
product,
isImported,
isBusy,
actionLabel = 'Add to my products',
onImport,
blockedReason,
onClose,
}: CatalogueDetailDrawerProps) {
/* Which of the 31 this product will be filed under, and why.
Shown, not asked. It used to be a dropdown here, defaulting to whatever the
merchant's list happened to hold — which is how a masala arrived on a shelf
as "Category 2". The classification is the catalogue team's, the same one
the customer app's filter is built on, so there is nothing for a person to
decide: what is left is telling them what it decided. */
const suggested = categoryForCatalogueProduct({
catalogueCategory: product.category,
title: product.product_name,
description: product.description ?? '',
packSize: product.size ?? '',
});
/* The app groups by subcategory, not category — see `appAisle.ts`. */
const aisle = aisleForCategory(suggested.category);
const images = product.images ?? [];
const [heroAt, setHeroAt] = useState(0);
const [isZoomed, setIsZoomed] = useState(false);
const [failed, setFailed] = useState(false);
// A different product in the same drawer starts at its own first photo.
useEffect(() => {
setHeroAt(0);
setFailed(false);
}, [product.brand, product.id]);
const hero = images[Math.min(heroAt, Math.max(images.length - 1, 0))];
const brand = product.brand.replace('brand_', '').toUpperCase();
const facts: { label: string; value: string; isMono?: boolean }[] = [];
if (product.category) facts.push({ label: 'Catalogue category', value: product.category });
if (product.product_sku) facts.push({ label: 'SKU', value: product.product_sku, isMono: true });
if (product.fssai_license) {
facts.push({ label: 'FSSAI licence', value: product.fssai_license, isMono: true });
}
if (product.providers?.length) {
facts.push({ label: 'Sold on', value: product.providers.join(', ') });
}
return (
<Drawer
title={product.product_name}
subtitle={`${brand} · catalogue id ${product.id}`}
width={540}
onClose={onClose}
{...(isImported ? { meta: <Badge label="In your list" colour="var(--color-success, #1f9d55)" /> } : {})}
{...(!isImported && !blockedReason && onImport
? {
isFooterFilled: true,
footer: (
<DrawerButton
label={isBusy ? 'Adding…' : actionLabel}
variant="primary"
icon={<DownloadCloud size={15} />}
isDisabled={Boolean(isBusy)}
onClick={onImport}
/>
),
}
: {})}
>
{/* The photograph, at the size a label can be read at. Click to zoom —
at card size the ingredients and the net weight are not legible. */}
<Section>
<button
type="button"
className="drawer-figure"
data-tall="true"
aria-label="Open photo full size"
onClick={() => setIsZoomed(true)}
disabled={!hero || failed}
style={{ width: '100%', cursor: hero && !failed ? 'zoom-in' : 'default' }}
>
{hero && !failed ? (
<img
src={hero}
alt={product.product_name}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{ mixBlendMode: 'multiply' }}
/>
) : (
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
)}
</button>
{images.length > 1 ? (
<>
<div className="drawer-thumbs">
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail
// and mis-track the rest as you page through them.
key={`${index}-${src}`}
className="drawer-thumb"
data-active={index === heroAt}
aria-label={`Show photo ${index + 1}`}
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
>
<img src={src} alt="" referrerPolicy="no-referrer" />
</button>
))}
</div>
<span className="drawer-metric-note">
{images.length} photos — only the first is imported
</span>
</>
) : null}
</Section>
{/* A RANGE, not a price. What the shop charges is set after the import,
and conflating the two is how a catalogue figure ends up on a shelf. */}
{/* 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">
<p className="drawer-prose">{product.description}</p>
</Section>
) : null}
{/* ── Health score ──────────────────────────────────────────────────
On the CATALOGUE drawer as well as the tenant one, and this is the
drawer where it actually has data: the scored products all live in the
global catalogue. A merchant's own shelf overlaps it barely at all
today — 0 of 26 — so a panel only on the tenant product page shows
nothing to anybody, which is exactly what happened.
`brand` and `image_id` come straight off the catalogue row, so no
lookup is needed to find the key. */}
<HealthScorePanel
product={{ productid: 0, productbrand: product.brand, imageid: product.image_id }}
category={product.category}
/>
{product.highlights?.length || product.nutrients?.length ? (
<div className="drawer-bullets">
{product.highlights?.length ? (
<div className="drawer-section">
<h4 className="drawer-section-title">Highlights</h4>
<Bullets items={product.highlights} />
</div>
) : null}
{product.nutrients?.length ? (
<div className="drawer-section">
<h4 className="drawer-section-title">Nutrition</h4>
<Bullets items={product.nutrients} />
</div>
) : null}
</div>
) : null}
{facts.length > 0 ? (
<Section title="Catalogue record">
<DrawerCard>
{facts.map((fact) => (
<Row
key={fact.label}
label={fact.label}
value={
fact.isMono ? (
<Mono>{fact.value}</Mono>
) : (
fact.value
)
}
/>
))}
</DrawerCard>
</Section>
) : null}
{/* The action, last, because everything above is what the decision is
made on. The button itself lives in the fixed bar; what stays here is
where it will be filed and the warning about what importing does not do. */}
{isImported ? (
<Banner
status="success"
title="Already in your products"
description="Set its price under Not ready in Products — that is the step that releases it to your shops."
icon={<Check size={16} />}
/>
) : blockedReason ? (
<Banner status="warning" title="Cannot import yet" description={blockedReason} />
) : onImport ? (
<Section title="Import into my products">
<DrawerCard>
<Row
label="Filed under"
value={<Badge label={suggested.category} colour="var(--color-brand)" />}
/>
{/* The heading a shopper actually reads. The catalogue's 31
categories are finer than the app's ten aisles, so the two are
shown side by side rather than one standing in for the other —
and a product with no aisle is told so here, not discovered
missing from the app later. */}
<Row
label="Shown in the app under"
value={
aisle ? (
<Badge label={aisle} colour="var(--color-success, #1f9d55)" />
) : (
<span style={{ color: 'var(--color-ink-3)' }}>Uncategorized</span>
)
}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14 }}>
<span className="drawer-caption">
{suggested.rule === 'catalogue'
? `The catalogue's own category for this product.`
: product.category
? `The catalogue files this under “${product.category}”, which is not one of the platform's 31 categories — so it is classified as ${suggested.category} instead, which is what the app's filter can show.`
: `The catalogue does not categorise this one, so it is classified from its name as ${suggested.category}.`}
</span>
<span className="drawer-caption">
{aisle
? 'Adds this product with no price. It reaches no shop and cannot be sold until you price and publish it.'
: 'Adds this product with no price, and with no aisle — the app will list it under “Uncategorized” until it can be classified. Price and publish it to put it on sale.'}
</span>
</div>
</DrawerCard>
</Section>
) : null}
{/* Gallery mode, driven by the same index the thumbnails set — so
opening the third photo and paging on from it leaves the drawer
showing the third photo when it closes. */}
{images.length > 0 ? (
<Lightbox
isOpen={isZoomed}
onOpenChange={setIsZoomed}
media={images.map((src, index) => ({
src,
alt: `${product.product_name} — photo ${index + 1}`,
}))}
index={Math.min(heroAt, images.length - 1)}
onIndexChange={setHeroAt}
hasZoom
/>
) : null}
</Drawer>
);
}

View File

@@ -0,0 +1,158 @@
import { useState } from 'react';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ChevronDown } from 'lucide-react';
import type { CatalogueBrand } from '@/api/types';
const COLLAPSED_COUNT = 25;
/**
* The brand rail.
*
* The reference this is built from lists shop departments — Groceries with
* Dairy, Bakery, Fruits nested underneath. Our catalogue has no departments: it
* is partitioned by BRAND, one Postgres table each, discovered from
* `information_schema` (`getbrands`), and categories exist only inside a brand
* because the endpoint that lists them refuses to answer without one
* (`catalogueController.go:38-45`).
*
* So the two levels are brand, then that brand's own categories — the same
* shape as the reference, filled with the structure the data actually has. A
* department tree over this data would be invented, and inventing a taxonomy is
* how products end up filed where nobody looks for them.
*/
export function CatalogueSidebar({
brands,
isLoading,
brand,
category,
categories,
isLoadingCategories,
totalCount,
onBrand,
onCategory,
}: {
brands: CatalogueBrand[];
isLoading: boolean;
brand: string;
category: 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;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const visible = isExpanded ? brands : brands.slice(0, COLLAPSED_COUNT);
const hidden = brands.length - visible.length;
return (
<aside className="catalogue-rail">
{/* The scroll lives on an inner element so the rounded corners can clip
it. With `overflow-y: auto` on the rounded box itself, the scrollbar
is painted inside the border box and squares off the two corners it
touches — one edge curved, the other cut. */}
<div className="rail-scroll">
<VStack gap={0}>
<button type="button" className="rail-all" data-active={!brand} onClick={() => onBrand('')}>
All brands
<span className="rail-count">
{isLoading
? ''
: (totalCount ?? brands.reduce((sum, entry) => sum + (entry.product_count ?? 0), 0))}
</span>
</button>
{isLoading ? (
<VStack gap={1} style={{ padding: '12px 4px' }}>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="rail-skeleton" />
))}
</VStack>
) : null}
{visible.map((entry) => {
const isOpen = entry.brand === brand;
return (
<div key={entry.brand} className="rail-group" data-open={isOpen}>
<button
type="button"
className="rail-brand"
data-active={isOpen}
aria-expanded={isOpen}
onClick={() => onBrand(isOpen ? '' : entry.brand)}
>
<span className="rail-brand-name">{entry.brand.replace('brand_', '')}</span>
<span className="rail-count">{entry.product_count}</span>
</button>
{/* The categories sit under the brand they belong to, which is
also the only place they can be asked for. */}
{isOpen ? (
<VStack gap={0} style={{ paddingBottom: 6 }}>
{isLoadingCategories ? (
<Text type="body" size="xsm" color="secondary" style={{ padding: '4px 12px' }}>
Loading categories…
</Text>
) : categories.length === 0 ? (
<Text type="body" size="xsm" color="secondary" style={{ padding: '4px 12px' }}>
No categories for this brand
</Text>
) : (
<>
<button
type="button"
className="rail-category"
data-active={!category}
onClick={() => onCategory('')}
>
All categories
</button>
{categories.map((entry) => (
<button
key={entry.value}
type="button"
className="rail-category"
data-active={category === entry.value}
onClick={() => onCategory(category === entry.value ? '' : entry.value)}
>
{entry.label}
</button>
))}
</>
)}
</VStack>
) : null}
</div>
);
})}
{hidden > 0 || isExpanded ? (
<button type="button" className="rail-more" onClick={() => setIsExpanded((on) => !on)}>
{isExpanded ? 'See less' : `See ${hidden} more`}
<ChevronDown size={13} style={{ transform: isExpanded ? 'rotate(180deg)' : undefined }} />
</button>
) : null}
</VStack>
</div>
</aside>
);
}

View File

@@ -0,0 +1,25 @@
/**
* The category the customer app browses.
*
* A constant because it is one on the app side too — its browse screen asks for
* `categoryid: 2` — and because a product filed anywhere else is invisible to
* shoppers rather than merely misfiled.
*
* Measured, not assumed: every tenant on the platform that has products reports
* category 2 and nothing else, and category 2 carries the ten real retail
* subcategories.
*
* It survives as a LAST RESORT only. Nothing picks a category any longer: every
* product is classified by `productCategory.ts` and the name exchanged for this
* merchant's id by `resolvecategories`. This is what a row falls back to when
* that call cannot be made at all — better a product on the shelf under the
* aisle the app browses than a product filed under 0, which no browse query
* returns.
*
* What used to live beside it, `categoryOptionsFor`, is gone with the pickers
* it fed. It offered a tenant's own categories and this constant as a floor,
* and its floor was the visible symptom: a merchant with no list yet was shown
* "General (the category the app shows)", chose it because it was the only
* entry, and every product they imported arrived as "Category 2".
*/
export const APP_BROWSE_CATEGORY = 2;

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