initial commit
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
||||
# What must never enter the build context.
|
||||
#
|
||||
# There was no .dockerignore at all, so every one of these was being uploaded to
|
||||
# the Docker daemon on each build and then copied into the builder image by
|
||||
# `COPY . .`.
|
||||
#
|
||||
# `.git` is the one that matters for speed — it carries the entire history, and
|
||||
# it grows forever while being worthless to a build. The rest is correctness:
|
||||
# `dist` and `node_modules` from a developer's machine would be copied in and
|
||||
# then overwritten by the build, but only after being transferred, and a stale
|
||||
# `dist` copied over the fresh one is a genuinely confusing failure.
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Transfer archives. The scratch files these rules were written for — the
|
||||
# `_to_delete` directory, `_inv.txt`, and two empty archives — are gone from the
|
||||
# repository; the glob stays so the next one never reaches a build context.
|
||||
*.zip
|
||||
*.tgz
|
||||
|
||||
# Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and
|
||||
# the build needs it — but `.env.local` is a developer's private override and
|
||||
# must not decide what production talks to.
|
||||
.env.local
|
||||
*.local
|
||||
|
||||
.vscode
|
||||
.DS_Store
|
||||
README.md
|
||||
13
.env
Normal file
@@ -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
@@ -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
@@ -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/
|
||||
131
Dockerfile
Normal 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
@@ -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
@@ -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
@@ -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
49
package.json
Normal 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
|
After Width: | Height: | Size: 11 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icon-16.png
Normal file
|
After Width: | Height: | Size: 803 B |
BIN
public/icon-180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/icon-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icon-48.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/logo-512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/logo-wordmark-light.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/logo-wordmark.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
262
scripts/appfix.mjs
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Repairs the products the customer app cannot show.
|
||||
*
|
||||
* node scripts/appfix.mjs # dry run — prints, writes nothing
|
||||
* node scripts/appfix.mjs --apply # performs the repair
|
||||
* node scripts/appfix.mjs --apply --tenant 1135
|
||||
*
|
||||
* `appsweep.mjs` finds them; this puts them right. Same detection, so the two
|
||||
* cannot disagree about what is broken.
|
||||
*
|
||||
* ── What it does, and why it is shaped like this ─────────────────────────────
|
||||
*
|
||||
* The repair is "give the product a category", and for a long time there was no
|
||||
* way to do it. `products/update` writes only `productlocations.status` despite
|
||||
* its name, and `importcatalogueproduct` took an existing product down a branch
|
||||
* that corrected the PRICE and left the category alone — so re-importing, the
|
||||
* obvious fix, appeared to work and changed nothing.
|
||||
*
|
||||
* That branch now also calls `UpdateProductCategory`
|
||||
* (`services/productService.go`), which makes re-import the repair path. This
|
||||
* script drives it: for each orphan it re-sends the original import with a real
|
||||
* `categoryid`.
|
||||
*
|
||||
* REQUIRES THE FIXED BACKEND. Against the currently deployed one every call
|
||||
* returns 200 and nothing changes, which is exactly the failure that makes this
|
||||
* bug expensive — so the script verifies each product afterwards and reports
|
||||
* what actually moved rather than what it asked for.
|
||||
*
|
||||
* ── What it deliberately does not do ─────────────────────────────────────────
|
||||
*
|
||||
* It sends `quantity: 0` and `stocktype: "in"`, so no stock ledger entry is
|
||||
* written — `CreateProductLocation` only records stock when quantity > 0. The
|
||||
* products already have their stock and this must not add to it.
|
||||
*
|
||||
* It re-sends each product's EXISTING price, cost and tax, because the same
|
||||
* call updates pricing. Sending zeros would wipe the prices while fixing the
|
||||
* category.
|
||||
*/
|
||||
|
||||
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
|
||||
const WEB = `${BASE}/live/api/v1/web`;
|
||||
|
||||
const apply = process.argv.includes('--apply');
|
||||
const tenantArg = process.argv.indexOf('--tenant');
|
||||
const onlyTenant = tenantArg > -1 ? Number(process.argv[tenantArg + 1]) : null;
|
||||
|
||||
async function get(path, params = {}) {
|
||||
const query = new URLSearchParams(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined && v !== ''),
|
||||
);
|
||||
const response = await fetch(`${WEB}${path}?${query}`, { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
return payload?.details ?? payload?.data ?? null;
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const response = await fetch(`${WEB}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return { ok: response.ok && payload?.status !== false, status: response.status, payload };
|
||||
}
|
||||
|
||||
async function tenantsWithOrphans() {
|
||||
const seen = new Map();
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? [];
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
for (const t of list) if (t?.tenantid) seen.set(t.tenantid, t);
|
||||
if (list.length < 200) break;
|
||||
}
|
||||
const out = [];
|
||||
for (const tenant of seen.values()) {
|
||||
if (onlyTenant && tenant.tenantid !== onlyTenant) continue;
|
||||
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
|
||||
const orphans = products.filter((p) => !p.categoryid);
|
||||
if (orphans.length === 0) continue;
|
||||
|
||||
/**
|
||||
* Where each orphan already sits.
|
||||
*
|
||||
* `getallproducts` is tenant-wide and carries NO locationid — the first
|
||||
* version of this read `p.locationid` off it, got undefined, sent 0, and
|
||||
* every repair came back "missing required field(s): locationid". Nothing
|
||||
* was written, which is the one good thing about that failure.
|
||||
*
|
||||
* The outlet matters beyond passing validation. Import writes a
|
||||
* productlocations row, so naming an outlet the product is NOT on would put
|
||||
* it on that shelf — silently extending the product's reach as a side
|
||||
* effect of a repair. Only an outlet where it is already stocked is safe:
|
||||
* there the upsert lands on the existing row.
|
||||
*/
|
||||
const locations = (await get('/tenants/gettenantlocations', { tenantid: tenant.tenantid })) ?? [];
|
||||
const placement = new Map();
|
||||
for (const loc of Array.isArray(locations) ? locations : []) {
|
||||
const rows = (await get('/products/getlocationproducts', {
|
||||
tenantid: tenant.tenantid,
|
||||
locationid: loc.locationid,
|
||||
pageno: 1,
|
||||
pagesize: 500,
|
||||
})) ?? [];
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
if (!placement.has(row.productid)) placement.set(row.productid, { loc, row });
|
||||
}
|
||||
}
|
||||
|
||||
out.push({ tenant, orphans, placement });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category to file a product under.
|
||||
*
|
||||
* `gettenantcategories` is synthesised from the categories the tenant's own
|
||||
* products already use, so it is the tenant's real answer rather than the
|
||||
* master table's — which is unscoped and, for the tenants seen here, offered a
|
||||
* category (1001) that the app does not browse.
|
||||
*
|
||||
* Refusing rather than guessing when the tenant has none: a wrong category is
|
||||
* findable and fixable, but writing one at random across a live catalogue is
|
||||
* not something a repair script should decide.
|
||||
*/
|
||||
async function categoryFor(tenantid) {
|
||||
const rows = (await get('/products/gettenantcategories', { tenantid })) ?? [];
|
||||
const usable = (Array.isArray(rows) ? rows : []).filter((r) => r?.categoryid > 0);
|
||||
return usable[0]?.categoryid ?? null;
|
||||
}
|
||||
|
||||
const work = await tenantsWithOrphans();
|
||||
|
||||
if (work.length === 0) {
|
||||
console.log('Nothing to repair — no product is missing a category.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(apply ? 'APPLYING repairs\n' : 'DRY RUN — nothing will be written. Pass --apply.\n');
|
||||
|
||||
let repaired = 0;
|
||||
let unchanged = 0;
|
||||
let refused = 0;
|
||||
|
||||
for (const { tenant, orphans, placement } of work) {
|
||||
const categoryid = await categoryFor(tenant.tenantid);
|
||||
console.log(`${tenant.tenantid} ${tenant.tenantname} — ${orphans.length} to repair`);
|
||||
|
||||
if (!categoryid) {
|
||||
console.log(' SKIPPED: this tenant has no category of its own to file into.\n');
|
||||
refused += orphans.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const p of orphans) {
|
||||
const label = `${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`;
|
||||
|
||||
if (!p.productbrand || !p.catalogueid) {
|
||||
// Not imported from the global catalogue, so brand+catalogueid cannot
|
||||
// address it and re-import is not available. Says so rather than
|
||||
// reporting a success it did not achieve.
|
||||
console.log(` ${label} — SKIPPED: no catalogue reference to re-import from`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const at = placement.get(p.productid);
|
||||
if (!at) {
|
||||
console.log(` ${label} — SKIPPED: not stocked at any outlet, so there is no safe row to repair through`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* A product with no price must not be made visible.
|
||||
*
|
||||
* Repairing the category is what puts a product in front of shoppers, and
|
||||
* the app has no price floor — `GetProducts` filters on category and outlet
|
||||
* and nothing else. Fixing a product priced at 0 would not "restore" it; it
|
||||
* would publish a free one. Idhayam Sesame Oil 500ml (7083) is in exactly
|
||||
* this state, priced nowhere, and it wants a price before it wants a
|
||||
* category.
|
||||
*/
|
||||
const price = Number(p.retailprice ?? 0);
|
||||
if (!(price > 0)) {
|
||||
console.log(` ${label} — SKIPPED: no price set. Repairing this would list it at ₹0. Price it first.`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(
|
||||
` ${label} → categoryid ${categoryid} (via ${at.loc.locationname ?? at.loc.locationid}, price ₹${price} unchanged)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await post('/products/importcatalogueproduct', [
|
||||
{
|
||||
tenantid: tenant.tenantid,
|
||||
// An outlet the product ALREADY sits at, so the upsert lands on the
|
||||
// existing row instead of putting it on a new shelf. The category
|
||||
// itself is written to `products`, which is tenant-wide, so one call
|
||||
// fixes the product everywhere.
|
||||
locationid: at.loc.locationid,
|
||||
brand: p.productbrand,
|
||||
catalogueid: p.catalogueid,
|
||||
categoryid,
|
||||
subcategoryid: p.subcategoryid ?? 0,
|
||||
// Zero, so CreateProductLocation writes no stock ledger entry — it only
|
||||
// records stock when quantity > 0. These products already have their
|
||||
// stock and a repair must not add to it.
|
||||
quantity: 0,
|
||||
stocktype: 'in',
|
||||
status: at.row.productstatus || p.productstatus || 'Active',
|
||||
// The product's OWN current values, re-sent unchanged. The same call
|
||||
// updates pricing, and the location upsert sets productlocations.price
|
||||
// from retailprice — verified equal for every product being repaired
|
||||
// here, so this round-trips rather than overwriting an outlet price.
|
||||
retailprice: price,
|
||||
productcost: p.productcost ?? 0,
|
||||
taxpercent: p.taxpercent ?? 0,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!result.ok) {
|
||||
console.log(` ${label} — FAILED: HTTP ${result.status} ${result.payload?.message ?? ''}`);
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verified, not assumed. The whole reason this bug survived is that the
|
||||
// call that was supposed to fix it returned success and did nothing.
|
||||
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||
const after = (Array.isArray(groups) ? groups : [])
|
||||
.flatMap((g) => g?.products ?? [])
|
||||
.find((x) => x.productid === p.productid);
|
||||
|
||||
if (after?.categoryid > 0) {
|
||||
console.log(` ${label} → categoryid ${after.categoryid} ✓`);
|
||||
repaired++;
|
||||
} else {
|
||||
console.log(
|
||||
` ${label} — NO CHANGE: the call succeeded but the category is still 0.` +
|
||||
' The backend fix is not deployed.',
|
||||
);
|
||||
unchanged++;
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`repaired ${repaired} · unchanged ${unchanged} · skipped ${refused}`);
|
||||
if (unchanged > 0) {
|
||||
console.log(
|
||||
'\nProducts reported NO CHANGE need the backend fix deployed' +
|
||||
' (services/productService.go — re-import must call UpdateProductCategory).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
193
scripts/appgap.mjs
Normal file
@@ -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
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Every product on the platform the customer app cannot show, and why.
|
||||
*
|
||||
* node scripts/appsweep.mjs
|
||||
* node scripts/appsweep.mjs --json > sweep.json
|
||||
*
|
||||
* `appgap.mjs` answers this for ONE outlet and is the tool to reach for when
|
||||
* somebody reports a specific shop. This is the platform-wide version: it walks
|
||||
* every tenant, in every approval state, and reports the products that are
|
||||
* unreachable no matter what the app asks for.
|
||||
*
|
||||
* ── The rule it applies ──────────────────────────────────────────────────────
|
||||
*
|
||||
* Read off the backend rather than inferred from responses
|
||||
* (`controllers/productController.go:431`, `repositories/productRepository.go:858`):
|
||||
*
|
||||
* 1. `GetProductsBySubcategory` REJECTS `categoryid = 0` with a 400 —
|
||||
* "Valid categoryid is required". It is the first thing the controller
|
||||
* does.
|
||||
* 2. The query then filters `WHERE a.categoryid = ?` unconditionally.
|
||||
*
|
||||
* Together those mean a product stored with `categoryid = 0` is returned for NO
|
||||
* request the app can make. Not "usually hidden" — unreachable. It is still
|
||||
* listed by `getlocationproducts`, which does not filter on category, so the
|
||||
* console shows it and the shop believes it is on sale.
|
||||
*
|
||||
* Nothing else in that query gates visibility: there is no filter on
|
||||
* `approved`, `publishedat`, `productstatus`, or stock level. A product with
|
||||
* zero stock still appears. So `categoryid = 0` is the whole defect, and this
|
||||
* script looks for exactly it.
|
||||
*/
|
||||
|
||||
const BASE = process.env['FIESTA_URL'] ?? 'https://fiesta.nearle.app';
|
||||
const WEB = `${BASE}/live/api/v1/web`;
|
||||
|
||||
const asJson = process.argv.includes('--json');
|
||||
|
||||
/** Fiesta answers under `details` in most places and `data` in a few. */
|
||||
async function get(path, params = {}) {
|
||||
const query = new URLSearchParams(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined && v !== ''),
|
||||
);
|
||||
const response = await fetch(`${WEB}${path}?${query}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} on ${path}`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
return payload?.details ?? payload?.data ?? null;
|
||||
}
|
||||
|
||||
/** Requests that never succeeded. A non-empty list invalidates the report. */
|
||||
const failures = [];
|
||||
|
||||
/**
|
||||
* Bounded concurrency, with retries, and failures that are never swallowed.
|
||||
*
|
||||
* The first version of this caught every error and substituted `null`. Run
|
||||
* across 262 tenants at a concurrency of 8, enough requests were refused that
|
||||
* it reported ONE affected tenant out of three known ones — and reported it as
|
||||
* a clean result, with no indication anything had gone wrong. A sweep that
|
||||
* fails silently is worse than no sweep: it is used to close the investigation.
|
||||
*
|
||||
* So: three attempts with a widening delay, and anything still failing is
|
||||
* recorded and printed at the end as an explicit gap in coverage.
|
||||
*/
|
||||
async function mapLimit(items, limit, fn, label = 'request') {
|
||||
const out = new Array(items.length);
|
||||
let next = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= items.length) return;
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
out[i] = await fn(items[i], i);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (cause) {
|
||||
lastError = cause;
|
||||
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
out[i] = null;
|
||||
failures.push(`${label}[${i}]: ${lastError?.message ?? lastError}`);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tenant, from two sources because neither is complete on its own.
|
||||
*
|
||||
* `getalltenants` paginates and its `pageno` is 1-BASED — page 0 returns an
|
||||
* empty list rather than the first page, the same off-by-one that
|
||||
* `getlocationproducts` has. It carries 262 tenants where the status lists
|
||||
* carry 142.
|
||||
*
|
||||
* `/tenants/search` is still needed alongside it: it branches on the word
|
||||
* "pending" and queries `approved = 0` instead of a status
|
||||
* (`tenantRepository.go:45-77`), which is the only way to learn that a tenant
|
||||
* is unapproved. Suriya Store is one.
|
||||
*
|
||||
* Building from the status lists ALONE was the first version's other bug: R
|
||||
* mart (1147) appears in none of the three, and a sweep that cannot see a
|
||||
* tenant reports it as having nothing wrong.
|
||||
*/
|
||||
async function allTenants() {
|
||||
const seen = new Map();
|
||||
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const rows = (await get('/tenants/getalltenants', { pageno: page, pagesize: 200 })) ?? [];
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
for (const t of list) if (t?.tenantid && !seen.has(t.tenantid)) seen.set(t.tenantid, { ...t });
|
||||
if (list.length < 200) break;
|
||||
}
|
||||
|
||||
for (const status of ['Active', 'pending', 'InActive']) {
|
||||
const rows = (await get('/tenants/search', { status })) ?? [];
|
||||
for (const t of Array.isArray(rows) ? rows : []) {
|
||||
if (!t?.tenantid) continue;
|
||||
const existing = seen.get(t.tenantid) ?? { ...t };
|
||||
seen.set(t.tenantid, { ...existing, approvalState: status });
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
const tenants = await allTenants();
|
||||
if (!asJson) console.error(`Scanning ${tenants.length} tenants…`);
|
||||
|
||||
/* Pass one — the whole platform, one request per tenant.
|
||||
`getallproducts` returns {tenant, products} groups, not a flat list. */
|
||||
const scanned = await mapLimit(tenants, 8, async (tenant) => {
|
||||
const groups = (await get('/products/getallproducts', { tenantid: tenant.tenantid })) ?? [];
|
||||
const products = (Array.isArray(groups) ? groups : []).flatMap((g) => g?.products ?? []);
|
||||
return { tenant, products, orphans: products.filter((p) => !p.categoryid) };
|
||||
}, "tenant-products");
|
||||
|
||||
const affected = scanned.filter((row) => row && row.orphans.length > 0);
|
||||
|
||||
/* Pass two — only the tenants that failed, so the outlet detail costs nothing
|
||||
on a clean platform. */
|
||||
const detailed = await mapLimit(affected, 6, async (row) => {
|
||||
const locations = (await get('/tenants/gettenantlocations', { tenantid: row.tenant.tenantid })) ?? [];
|
||||
const branches = await mapLimit(Array.isArray(locations) ? locations : [], 4, async (loc) => {
|
||||
const shelved = (await get('/products/getlocationproducts', {
|
||||
tenantid: row.tenant.tenantid,
|
||||
locationid: loc.locationid,
|
||||
pageno: 1,
|
||||
pagesize: 500,
|
||||
})) ?? [];
|
||||
const list = Array.isArray(shelved) ? shelved : [];
|
||||
const hidden = list.filter((p) => !p.categoryid);
|
||||
return {
|
||||
locationid: loc.locationid,
|
||||
locationname: loc.locationname,
|
||||
shelved: list.length,
|
||||
hidden: hidden.length,
|
||||
// The number that matters to a shopper: an outlet whose entire range is
|
||||
// invisible looks like a closed shop, not like a partial catalogue.
|
||||
visible: list.length - hidden.length,
|
||||
};
|
||||
});
|
||||
return { ...row, branches: branches.filter(Boolean) };
|
||||
}, "tenant-branches");
|
||||
|
||||
/* Coverage is part of the result, not a footnote. A tenant whose products
|
||||
never loaded is UNKNOWN, not clean, and the difference decides whether this
|
||||
report can be used to say the platform is fixed. */
|
||||
const unreached = scanned.filter((row) => !row).length;
|
||||
|
||||
if (asJson) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
scannedTenants: tenants.length,
|
||||
affectedTenants: detailed.length,
|
||||
orphanProducts: detailed.reduce((n, r) => n + r.orphans.length, 0),
|
||||
tenants: detailed.map((r) => ({
|
||||
tenantid: r.tenant.tenantid,
|
||||
tenantname: r.tenant.tenantname,
|
||||
approvalState: r.tenant.approvalState,
|
||||
totalProducts: r.products.length,
|
||||
orphans: r.orphans.map((p) => ({
|
||||
productid: p.productid,
|
||||
productname: p.productname,
|
||||
productbrand: p.productbrand,
|
||||
catalogueid: p.catalogueid,
|
||||
retailprice: p.retailprice,
|
||||
})),
|
||||
branches: r.branches,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const orphanCount = detailed.reduce((n, r) => n + r.orphans.length, 0);
|
||||
const blindOutlets = detailed.flatMap((r) =>
|
||||
r.branches.filter((b) => b.shelved > 0 && b.visible === 0),
|
||||
);
|
||||
|
||||
console.log(`\n${tenants.length} tenants scanned`);
|
||||
console.log(`${detailed.length} affected`);
|
||||
console.log(`${orphanCount} products with categoryid 0 — invisible in the app`);
|
||||
console.log(`${blindOutlets.length} outlets stocked but showing NOTHING to shoppers\n`);
|
||||
|
||||
if (unreached > 0 || failures.length > 0) {
|
||||
console.log(
|
||||
`!! ${unreached} tenants could not be read after 3 attempts — this report is INCOMPLETE
|
||||
`,
|
||||
);
|
||||
for (const f of failures.slice(0, 10)) console.log(` ${f}`);
|
||||
if (failures.length > 10) console.log(` … ${failures.length - 10} more
|
||||
`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
for (const row of detailed.sort((a, b) => b.orphans.length - a.orphans.length)) {
|
||||
const { tenant } = row;
|
||||
console.log(
|
||||
`${tenant.tenantid} ${tenant.tenantname}` +
|
||||
` — ${row.orphans.length}/${row.products.length} products hidden` +
|
||||
(tenant.approvalState === 'pending' ? ' [unapproved]' : ''),
|
||||
);
|
||||
for (const p of row.orphans) {
|
||||
console.log(` ${String(p.productid).padEnd(6)} ${p.productname ?? '(unnamed)'}`);
|
||||
}
|
||||
for (const b of row.branches) {
|
||||
if (b.shelved === 0) continue;
|
||||
const flag = b.visible === 0 ? ' ← app shows an EMPTY shop' : '';
|
||||
console.log(
|
||||
` · ${String(b.locationname ?? b.locationid).padEnd(28)}` +
|
||||
` ${b.visible}/${b.shelved} visible${flag}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
91
scripts/checkHealthPanel.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
85
src/api/catalogueKey.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Which key a catalogue product is recognised by.
|
||||
*
|
||||
* This decides whether the browse screen shows a product as already imported.
|
||||
* Getting it wrong is not cosmetic: a shop's own stock shown as missing gets
|
||||
* imported a second time, and the shop ends up with duplicates.
|
||||
*
|
||||
* The reason it changed: `catalogueid` is renumbered by every re-scrape.
|
||||
* Pepsico's live ids run 3, 6, 9 … 27, 30 — there is no 19, 25 or 26 — so on
|
||||
* 2026-08-31 eleven of the nineteen links on the platform pointed at rows that
|
||||
* no longer existed. `image_id` is the key the catalogue itself deduplicates on
|
||||
* and survives both a renumber and a rename.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { catalogueKey, catalogueKeysOf } from './catalogue';
|
||||
|
||||
test('a product with a stable key is identified by it, not by its id', () => {
|
||||
assert.equal(
|
||||
catalogueKey({ brand: 'pepsico', id: 27, image_id: 'cheetos_chips_2d6bf74f' } as never),
|
||||
'img:cheetos_chips_2d6bf74f',
|
||||
);
|
||||
assert.equal(
|
||||
catalogueKey({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' } as never),
|
||||
'img:cheetos_chips_2d6bf74f',
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
The two sides have to agree. A ref from Fiesta and a product from the catalogue
|
||||
describe the same thing under different field names — `imageid` and `image_id` —
|
||||
and if they produced different keys the tick would never appear at all.
|
||||
*/
|
||||
test('a ref and a catalogue row agree on the key', () => {
|
||||
const fromFiesta = catalogueKey({
|
||||
brand: 'pepsico',
|
||||
catalogueid: 27,
|
||||
imageid: 'cheetos_chips_2d6bf74f',
|
||||
} as never);
|
||||
const fromCatalogue = catalogueKey({
|
||||
brand: 'pepsico',
|
||||
id: 27,
|
||||
image_id: 'cheetos_chips_2d6bf74f',
|
||||
} as never);
|
||||
assert.equal(fromFiesta, fromCatalogue);
|
||||
});
|
||||
|
||||
// The fallback still has to work: products imported before the column existed
|
||||
// carry only the id, and they are genuinely imported.
|
||||
test('without a stable key the brand-qualified id is used', () => {
|
||||
assert.equal(catalogueKey({ brand: 'dabur', catalogueid: 19 } as never), 'dabur:19');
|
||||
assert.equal(catalogueKey({ brand: 'dabur', id: 19 } as never), 'dabur:19');
|
||||
});
|
||||
|
||||
// Brand-qualified, never bare. Each brand is its own table with its own
|
||||
// sequence, so dabur 19 and pepsico 19 both exist and a bare id would tick the
|
||||
// wrong product.
|
||||
test('the fallback key keeps the brand, because ids repeat across brands', () => {
|
||||
assert.notEqual(
|
||||
catalogueKey({ brand: 'dabur', catalogueid: 19 } as never),
|
||||
catalogueKey({ brand: 'pepsico', catalogueid: 19 } as never),
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
During the changeover a ref carries both. Emitting only the stable key would
|
||||
make every not-yet-relinked product read as missing the moment this shipped —
|
||||
turning a silent problem into a visible one on every shop at once.
|
||||
*/
|
||||
test('a ref is recognised by both keys while the changeover runs', () => {
|
||||
assert.deepEqual(
|
||||
catalogueKeysOf({ brand: 'pepsico', catalogueid: 27, imageid: 'cheetos_chips_2d6bf74f' }),
|
||||
['img:cheetos_chips_2d6bf74f', 'pepsico:27'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a ref with only an id still yields its one key', () => {
|
||||
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 19 }), ['dabur:19']);
|
||||
});
|
||||
|
||||
// A ref with neither yields nothing rather than a key like "undefined:0" that
|
||||
// would collide with every other broken ref and tick unrelated products.
|
||||
test('a ref with nothing to match on yields no keys at all', () => {
|
||||
assert.deepEqual(catalogueKeysOf({ brand: 'dabur', catalogueid: 0 }), []);
|
||||
});
|
||||
305
src/api/client.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
100
src/api/uploads.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The receipt, and the two things it has to get right.
|
||||
*
|
||||
* A receipt exists because the ingest service's batch id is the only credential
|
||||
* for reading a result back, it is handed out once, and an unreviewed drop is
|
||||
* deleted after seven days. So the tests that matter are about not losing that
|
||||
* window, and about the label their admin reads when deciding whether to
|
||||
* approve the file.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { buildSender, daysUntilExpiry, DROP_RETENTION_DAYS, type UploadReceipt } from './uploads';
|
||||
|
||||
function receipt(overrides: Partial<UploadReceipt> = {}): UploadReceipt {
|
||||
return {
|
||||
uploadid: 1,
|
||||
tenantid: 1141,
|
||||
locationid: 1180,
|
||||
categoryid: 2,
|
||||
batchid: '49a82536866a483a9189954d3c749243',
|
||||
runid: '',
|
||||
filename: 'kmart-opening.xlsx',
|
||||
sender: 'Kmart · Peelamedu · abhishek',
|
||||
uploadedby: 1475,
|
||||
uploadedname: 'abhishek',
|
||||
rowcount: 20,
|
||||
laststatus: 'pending',
|
||||
inserted: 0,
|
||||
backfilled: 0,
|
||||
skipped: 0,
|
||||
rejected: 0,
|
||||
shelvedcount: 0,
|
||||
skippedcount: 0,
|
||||
shelvedat: null,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
|
||||
|
||||
test('a drop uploaded today has the full window left', () => {
|
||||
assert.equal(daysUntilExpiry(receipt()), DROP_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
test('the window closes as the drop sits unreviewed', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(5) })), 2);
|
||||
});
|
||||
|
||||
// Never negative. A drop past the deadline is gone, and "-3 days left" would
|
||||
// read as a countdown that is still running.
|
||||
test('an expired drop reports zero, not a negative', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ created: daysAgo(30) })), 0);
|
||||
});
|
||||
|
||||
/*
|
||||
Retention applies to a drop nobody acted on. Once an admin releases it the run
|
||||
is the record and the drop's own expiry is irrelevant — showing a countdown on
|
||||
a released upload would push someone to chase a deadline that has already been
|
||||
met.
|
||||
*/
|
||||
test('a released drop has no expiry to report', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ runid: '8dcef8a2ad94', laststatus: 'retired' })), null);
|
||||
assert.equal(daysUntilExpiry(receipt({ laststatus: 'done' })), null);
|
||||
});
|
||||
|
||||
test('a receipt with an unreadable date reports nothing rather than guessing', () => {
|
||||
assert.equal(daysUntilExpiry(receipt({ created: 'not a date' })), null);
|
||||
});
|
||||
|
||||
/*
|
||||
The sender label. Free text on their side, capped at 60 characters, and shown to
|
||||
the admin who decides whether to run the file — so it has to identify the shop,
|
||||
not the console.
|
||||
*/
|
||||
test('the sender names the merchant, the branch and the person', () => {
|
||||
assert.equal(
|
||||
buildSender({ tenantname: 'Kmart', locationname: 'Peelamedu', username: 'abhishek' }),
|
||||
'Kmart · Peelamedu · abhishek',
|
||||
);
|
||||
});
|
||||
|
||||
// The tenant name is truncated, not the whole label. Cutting the tail would
|
||||
// drop the branch and the person — the two parts that say WHICH shelf and WHO —
|
||||
// and leave only a long restaurant name that identifies neither.
|
||||
test('a long merchant name is trimmed so the branch and person survive', () => {
|
||||
const sender = buildSender({
|
||||
tenantname: 'Ninhao The New Age Chinese Restaurant',
|
||||
locationname: 'Race Course',
|
||||
username: 'abhishek',
|
||||
});
|
||||
assert.ok(sender.length <= 60, `sender was ${sender.length} chars: ${sender}`);
|
||||
assert.ok(sender.includes('Race Course'), sender);
|
||||
assert.ok(sender.includes('abhishek'), sender);
|
||||
});
|
||||
|
||||
test('a sender with nothing to say still labels the console', () => {
|
||||
assert.equal(buildSender({}), 'nearle-console');
|
||||
});
|
||||
220
src/api/uploads.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
59
src/auth/workspace.test.ts
Normal 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
@@ -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';
|
||||
}
|
||||
}
|
||||
65
src/components/DataState.tsx
Normal 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}</>;
|
||||
}
|
||||
139
src/components/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/components/Freshness.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/KpiCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/PageBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/PageHeader.tsx
Normal 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
@@ -0,0 +1,139 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import './panel.css';
|
||||
|
||||
/**
|
||||
* The console's card. Every surface that holds a table, a list or a form is
|
||||
* one of these.
|
||||
*
|
||||
* ── Why it has three zones and the old card had one ─────────────────────────
|
||||
*
|
||||
* The card this replaces was a bare rectangle: `<Card padding={0}>` with a
|
||||
* table inside it and nothing else. The title, the row count, the search box
|
||||
* and the tabs all lived ABOVE it, in the page header, so the box itself said
|
||||
* nothing — it drew a line around a table and stopped. No edge treatment fixes
|
||||
* that. A border, a shadow, a gradient: a rectangle with no anatomy reads as
|
||||
* unfinished whichever way you paint its outline, which is exactly what it
|
||||
* looked like.
|
||||
*
|
||||
* So the card frames its content now:
|
||||
*
|
||||
* ┌───────────────────────────────────────┐
|
||||
* │ PRODUCTS 1,248 [Filter] [+ Add] │ header — what this is,
|
||||
* ├───────────────────────────────────────┤ how much of it,
|
||||
* │ …the table… │ what you can do
|
||||
* ├───────────────────────────────────────┤
|
||||
* │ 1–25 of 1,248 ‹ 1 2 3 › │ footer — where you are in it
|
||||
* └───────────────────────────────────────┘
|
||||
*
|
||||
* The header is not decoration. It is the three facts a person needs before
|
||||
* reading a table, in the place the table is, rather than floating above it
|
||||
* where they belong to the page instead of to the data.
|
||||
*
|
||||
* ── The edge ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* A hairline plus a 1px micro-lift — `0 1px 2px` at 4%. Not a blur: a wide
|
||||
* soft shadow makes a panel float, and a page of five floating panels is a
|
||||
* tray of slabs. This is just enough to separate the panel from the canvas
|
||||
* behind it and to make the hairline read as an edge rather than as a drawn
|
||||
* line.
|
||||
*/
|
||||
|
||||
export interface PanelProps {
|
||||
/**
|
||||
* The eyebrow. Set in caps at 11px, because it labels the panel rather than
|
||||
* titling the page — a 20px heading inside a card competes with the page's
|
||||
* own, and the two stacked is what made the old layout repeat itself.
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* The row count, beside the title in its own chip.
|
||||
*
|
||||
* Passed as a number and formatted here, so every panel in the console counts
|
||||
* the same way. It is the first thing anybody checks against a filter.
|
||||
*/
|
||||
count?: number | undefined;
|
||||
/** A word for what is counted — "products", "orders". Default: nothing. */
|
||||
countLabel?: string;
|
||||
/** Search, filters, a primary action. Right-aligned in the header band. */
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* The footer band — in practice always a `<TablePager>`.
|
||||
*
|
||||
* Absent when the pager has nothing to say: `TablePager` returns null on a
|
||||
* single page, and a footer band rendered around null is a 44px strip of
|
||||
* tinted chrome under every short table. Pass it through `footer` and this
|
||||
* decides; do not put a pager in `children`.
|
||||
*/
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Padding for the body. `none` for a table, which brings its own; `md` for
|
||||
* prose, a form or an empty state.
|
||||
*/
|
||||
padding?: 'none' | 'md';
|
||||
/** Stretches the panel to fill a grid row, for a row of unequal panels. */
|
||||
isFilled?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
count,
|
||||
countLabel,
|
||||
actions,
|
||||
footer,
|
||||
padding = 'none',
|
||||
isFilled,
|
||||
children,
|
||||
}: PanelProps) {
|
||||
const hasHead = Boolean(title || actions || count !== undefined);
|
||||
|
||||
return (
|
||||
<section className="panel-card" {...(isFilled ? { 'data-filled': 'true' } : {})}>
|
||||
{hasHead ? (
|
||||
<header className="panel-card-head">
|
||||
<div className="panel-card-title-group">
|
||||
{title ? <h2 className="panel-card-title">{title}</h2> : null}
|
||||
{/* Rendered at zero too. "0 products" is a fact — the filter
|
||||
matched nothing — and hiding it leaves an empty table with no
|
||||
explanation of whether it is empty or still loading. */}
|
||||
{count === undefined ? null : (
|
||||
<span className="panel-card-count">
|
||||
{count.toLocaleString('en-IN')}
|
||||
{countLabel ? <span className="panel-card-count-label">{countLabel}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{actions ? <div className="panel-card-actions">{actions}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
<div className="panel-card-body" data-pad={padding}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* `footer` is rendered only when it is truthy, and `TablePager` returns
|
||||
null rather than false — so a single-page table passes an element that
|
||||
renders nothing, and the band would still draw. The check is on the
|
||||
pager's own emptiness instead: see `hasFooter` below. */}
|
||||
{hasFooter(footer) ? <footer className="panel-card-foot">{footer}</footer> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the footer has anything in it.
|
||||
*
|
||||
* `<TablePager>` returns `null` from its own render when there is one page, but
|
||||
* the ELEMENT is still truthy here — React has not called it yet. So a plain
|
||||
* `footer ? … : null` drew an empty tinted band under every table short enough
|
||||
* not to need a pager, which is the one place this design could have looked
|
||||
* worse than the bare box it replaces.
|
||||
*
|
||||
* The band is therefore driven by CSS instead: `:empty` cannot see through the
|
||||
* component boundary, so the footer element is always rendered when a footer is
|
||||
* passed, and `.panel-card-foot:not(:has(*))` collapses it to nothing when the
|
||||
* pager rendered no DOM. `:has` is supported everywhere this console runs.
|
||||
*/
|
||||
function hasFooter(footer: ReactNode): boolean {
|
||||
return footer !== null && footer !== undefined && footer !== false;
|
||||
}
|
||||
70
src/components/SearchInput.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
39
src/components/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
src/components/SheetDropzone.tsx
Normal 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`;
|
||||
}
|
||||
59
src/components/StickyRow.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* A row of page controls held under the nav bar while the page scrolls.
|
||||
*
|
||||
* Whatever sits above a page — its view tabs, its search, its action buttons —
|
||||
* used to leave with the scroll. Reaching the bottom of a long list put every
|
||||
* control over that list off the top of the window, and the only way back to
|
||||
* them was to scroll back through everything just read. The further in somebody
|
||||
* was, the further from the controls.
|
||||
*
|
||||
* The page still scrolls normally. Nothing here gets a scrollbar of its own —
|
||||
* the row simply stays where it is. Positioning and the canvas it paints live
|
||||
* in `.page-sticky`; this component exists for the one part CSS cannot answer,
|
||||
* which is whether the row is currently pinned.
|
||||
*
|
||||
* `PageHeader` wraps itself in this, so every page built from it gets the
|
||||
* behaviour without asking. Dispatch builds its own switcher rather than going
|
||||
* through `PageHeader`, so it wraps itself — which is the reason this is a
|
||||
* component and not four more lines inside the header.
|
||||
*/
|
||||
export function StickyRow({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* Pinned or not, so the hairline can be drawn only when it is.
|
||||
*
|
||||
* At rest the nav bar's own hairline sits just above this one, and two
|
||||
* parallel lines across the page with nothing between them read as a mistake.
|
||||
* The line is what separates the row from content passing UNDER it, so it
|
||||
* belongs to the pinned state rather than to the row.
|
||||
*
|
||||
* Watched with an observer rather than a scroll handler: a scroll listener
|
||||
* runs on every frame of every scroll to answer a question that changes
|
||||
* twice. The negative top margin puts the observer's boundary a pixel above
|
||||
* where the row comes to rest, so "no longer fully inside that box" means
|
||||
* exactly "stuck".
|
||||
*/
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = rowRef.current;
|
||||
/* Guarded for any renderer without it — the row still sticks, it just
|
||||
never grows its line, which is a missing hairline rather than a crash. */
|
||||
if (!element || typeof IntersectionObserver === 'undefined') return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)),
|
||||
{ rootMargin: '-57px 0px 0px 0px', threshold: 1 },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rowRef} className="page-sticky" data-stuck={isStuck ? 'true' : 'false'}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/TabBar.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import './tabBar.css';
|
||||
|
||||
/**
|
||||
* The console's view switcher — Orders / Deliveries / Counter sales, Catalogue
|
||||
* / My stock / My requests, Store users / Terminal accounts / Riders.
|
||||
*
|
||||
* ── There were six of these ─────────────────────────────────────────────────
|
||||
*
|
||||
* `TabButton` was defined privately in `StoresPage`, `InventoryPage`,
|
||||
* `ReportsPage`, `SalesPage`, `UsersPage` and `store-user/ui.tsx` — six copies
|
||||
* of the same button, each styled inline, and they had already drifted: three
|
||||
* of them filled the count badge with `--color-warning`, so "85" under Orders
|
||||
* was drawn in the colour this console reserves for something being wrong. A
|
||||
* count is not an alarm. Sales had the sensible version. Nobody could see the
|
||||
* difference because no two of them are ever on screen together.
|
||||
*
|
||||
* ── Why it is a track and not loose pills ───────────────────────────────────
|
||||
*
|
||||
* The old buttons were transparent until selected, so an unselected set read as
|
||||
* four pieces of text floating above the page with no indication they were one
|
||||
* control or that picking one deselects the others. They sit in a recessed
|
||||
* track now, with the selected tab raised out of it on the card surface — the
|
||||
* standard segmented control, and the one shape that says "these are the views,
|
||||
* you are in this one" without needing a label to say so.
|
||||
*/
|
||||
|
||||
export interface TabBarProps {
|
||||
children: ReactNode;
|
||||
/** `sm` for a secondary strip — a filter under a primary switcher. */
|
||||
size?: 'md' | 'sm';
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export function TabBar({ children, size = 'md', ...rest }: TabBarProps) {
|
||||
return (
|
||||
<div className="tabbar" data-size={size} role="tablist" aria-label={rest['aria-label']}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* The count beside the label. Omit it and no count is drawn.
|
||||
*
|
||||
* Whether ZERO is worth drawing depends on what the tab is, so the caller
|
||||
* decides rather than this component:
|
||||
*
|
||||
* - A VIEW passes `x.length || undefined`. "Deliveries 0" beside "Orders
|
||||
* 85" reads as a broken tab; "Deliveries" alone says nothing and lets the
|
||||
* empty state explain.
|
||||
* - A STATUS in a ladder passes the raw count. "Cancelled 0" is the answer
|
||||
* to a question the reader is asking — nothing was cancelled — and a rung
|
||||
* that loses its number when it empties makes the ladder jump about as
|
||||
* the day goes on.
|
||||
*/
|
||||
count?: number | undefined;
|
||||
/**
|
||||
* Draws a hairline before this tab, dividing the track into groups.
|
||||
*
|
||||
* Sales needs it: Orders and Deliveries are two halves of the app's own
|
||||
* trade, and Counter sales is a different channel entirely. The rule says so
|
||||
* without spending a tab on a heading.
|
||||
*/
|
||||
isGroupStart?: boolean;
|
||||
/**
|
||||
* A pulsing dot after the label, for a view that updates by itself.
|
||||
*
|
||||
* Dispatch's Active Fleet is the one: it reads rider positions live, and the
|
||||
* dot is how the board says so. A named prop rather than a general-purpose
|
||||
* slot — a `trailing` that took any node would be the seam this component
|
||||
* exists to close, and there is exactly one live view in the console.
|
||||
*/
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export function Tab({ label, icon, isActive, onClick, count, isGroupStart, isLive }: TabProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className="tabbar-tab"
|
||||
aria-selected={isActive}
|
||||
{...(isGroupStart ? { 'data-group-start': 'true' } : {})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon ? <span className="tabbar-icon">{icon}</span> : null}
|
||||
<span className="tabbar-label">{label}</span>
|
||||
{count === undefined ? null : <span className="tabbar-count">{count}</span>}
|
||||
{/* Labelled, not decorative: a green dot with no accessible name is a
|
||||
colour that means something to sighted readers only. */}
|
||||
{isLive ? <span className="tabbar-live" role="img" aria-label="updating live" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
49
src/components/TablePager.tsx
Normal file
@@ -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
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './trailMap.css';
|
||||
|
||||
/**
|
||||
* A map, for things that have a position.
|
||||
*
|
||||
* ── Why leaflet directly and not react-leaflet ──────────────────────────────
|
||||
*
|
||||
* A leaflet map is an imperative object that owns a DOM node and must be torn
|
||||
* down by hand — `remove()`, or the tile layer keeps fetching and the container
|
||||
* keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in
|
||||
* components and adds a second package that has to track React's major version
|
||||
* forever. The wrapping is about forty lines; it is written here instead.
|
||||
*
|
||||
* ── Why the pins are divIcons ───────────────────────────────────────────────
|
||||
*
|
||||
* Leaflet's default marker is a PNG resolved relative to the stylesheet, which
|
||||
* every bundler rewrites and breaks — the classic "markers are invisible"
|
||||
* bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is
|
||||
* markup, so it ships with the bundle, takes the console's brand colour and
|
||||
* needs no image at all.
|
||||
*/
|
||||
|
||||
export interface MapTrail {
|
||||
id: string | number;
|
||||
label: string;
|
||||
points: readonly { lat: number; lng: number }[];
|
||||
colour: string;
|
||||
/**
|
||||
* Drawn thinner and paler, so a focused round reads against the others.
|
||||
*
|
||||
* Muted rather than hidden: a rider's route is still context for the rider
|
||||
* beside them, and hiding it would make two overlapping rounds impossible to
|
||||
* compare — which is the reason to focus one in the first place.
|
||||
*/
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
export interface MapPinPopup {
|
||||
title?: string;
|
||||
orderId?: string;
|
||||
customer?: string;
|
||||
address?: string;
|
||||
status?: string;
|
||||
statusColor?: string;
|
||||
time?: string;
|
||||
rider?: string;
|
||||
amount?: string;
|
||||
step?: number;
|
||||
subtitle?: string;
|
||||
items?: { orderid: string; status: string }[];
|
||||
}
|
||||
|
||||
export interface MapPin {
|
||||
id: string | number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
/** Shown under the label in the popup. Plain text, one line per entry. */
|
||||
lines?: string[];
|
||||
popup?: MapPinPopup;
|
||||
popupHtml?: string;
|
||||
colour?: string;
|
||||
/** A hollow ring rather than a filled pin — for a last-known, not-live point. */
|
||||
isFaded?: boolean;
|
||||
/**
|
||||
* A square marks a place rather than an event — a shop, a depot. Round pins
|
||||
* are the things that happened there, so a branch never reads as one more
|
||||
* customer among its own drops.
|
||||
*/
|
||||
shape?: 'round' | 'square';
|
||||
/**
|
||||
* The stop's place in the round, drawn inside the pin.
|
||||
*
|
||||
* Without it a route is a line through identical dots and there is no way to
|
||||
* read which end it started from — the map says "this is the order it was
|
||||
* worked" and then withholds the order. The console this replaces numbers
|
||||
* every drop for the same reason.
|
||||
*/
|
||||
step?: number;
|
||||
/** Ringed as the round's first stop. */
|
||||
isStart?: boolean;
|
||||
/**
|
||||
* Dim this pin. Used when another round is focused, so the one being read
|
||||
* stands out without the rest disappearing — a rider's stops are still
|
||||
* context for the rider beside them.
|
||||
*/
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
/** Tamil Nadu, so an empty map still shows the right part of the world. */
|
||||
const FALLBACK: L.LatLngExpression = [11.0168, 76.9558];
|
||||
|
||||
export function TrailMap({
|
||||
trails = [],
|
||||
pins = [],
|
||||
height = 380,
|
||||
emptyNote = 'Nothing to place on the map yet.',
|
||||
}: {
|
||||
trails?: readonly MapTrail[];
|
||||
pins?: readonly MapPin[];
|
||||
height?: number;
|
||||
emptyNote?: string;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement | null>(null);
|
||||
const map = useRef<L.Map | null>(null);
|
||||
/* Everything drawn, kept together so a redraw clears exactly what it drew.
|
||||
Clearing the map wholesale would take the tile layer with it. */
|
||||
const drawn = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!host.current || map.current) return;
|
||||
const instance = L.map(host.current, {
|
||||
center: FALLBACK,
|
||||
zoom: 12,
|
||||
// The console scrolls; a wheel over the map should scroll the page, not
|
||||
// zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people
|
||||
// expect from a map embedded in a document.
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: true,
|
||||
});
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(instance);
|
||||
drawn.current = L.layerGroup().addTo(instance);
|
||||
map.current = instance;
|
||||
|
||||
return () => {
|
||||
instance.remove();
|
||||
map.current = null;
|
||||
drawn.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const instance = map.current;
|
||||
const layer = drawn.current;
|
||||
if (!instance || !layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
for (const trail of trails) {
|
||||
if (trail.points.length < 2) continue;
|
||||
const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]);
|
||||
|
||||
/* A white casing under the coloured line.
|
||||
Map tiles are busy and mid-toned, and a 3px stroke of any colour
|
||||
disappears into a main road drawn underneath it. The casing gives the
|
||||
line its own edge so it reads as something laid ON the map rather than
|
||||
part of it. Borrowed from the console this replaces, which draws the
|
||||
same pair — it is the one thing that made its routes legible. */
|
||||
L.polyline(line, {
|
||||
color: '#ffffff',
|
||||
weight: 7,
|
||||
// Muted with its line, or a dimmed route shows a bright white casing
|
||||
// and ends up louder than the one being focused.
|
||||
opacity: trail.isMuted ? 0.2 : 0.65,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
interactive: false,
|
||||
}).addTo(layer);
|
||||
|
||||
L.polyline(line, {
|
||||
color: trail.colour,
|
||||
weight: trail.isMuted ? 2.5 : 3.5,
|
||||
opacity: trail.isMuted ? 0.35 : 0.95,
|
||||
// Rounded joins, or a dense trail draws spikes at every turn.
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
})
|
||||
.bindTooltip(trail.label, { sticky: true })
|
||||
.addTo(layer);
|
||||
|
||||
line.forEach((point) => bounds.extend(point));
|
||||
}
|
||||
|
||||
for (const pin of pins) {
|
||||
const colour = pin.colour ?? 'var(--color-brand)';
|
||||
L.marker([pin.lat, pin.lng], {
|
||||
title: pin.label,
|
||||
icon: L.divIcon({
|
||||
className: 'trail-pin-wrap',
|
||||
// A numbered pin has to be big enough to hold two digits legibly, so
|
||||
// the size follows the content rather than being fixed.
|
||||
html:
|
||||
`<span class="trail-pin" data-faded="${pin.isFaded ? 'true' : 'false'}"` +
|
||||
` data-shape="${pin.shape ?? 'round'}"` +
|
||||
` data-muted="${pin.isMuted ? 'true' : 'false'}"` +
|
||||
` data-numbered="${pin.step ? 'true' : 'false'}"` +
|
||||
` data-start="${pin.isStart ? 'true' : 'false'}"` +
|
||||
` style="--pin:${escapeAttr(colour)}">` +
|
||||
`${pin.step ? escapeHtml(String(pin.step)) : ''}</span>`,
|
||||
iconSize: pin.step ? [22, 22] : [16, 16],
|
||||
iconAnchor: pin.step ? [11, 11] : [8, 8],
|
||||
}),
|
||||
})
|
||||
.bindPopup(pin.popupHtml ?? formatPinPopup(pin), {
|
||||
className: 'trail-leaflet-popup-card',
|
||||
closeButton: true,
|
||||
maxWidth: 290,
|
||||
minWidth: 230,
|
||||
autoPanPadding: [20, 20],
|
||||
})
|
||||
.addTo(layer);
|
||||
bounds.extend([pin.lat, pin.lng]);
|
||||
}
|
||||
|
||||
if (bounds.isValid()) {
|
||||
// `maxZoom` matters: a single pin, or a rider who never left one street,
|
||||
// otherwise zooms to building level and the map shows one grey rectangle
|
||||
// with no landmarks to orient by.
|
||||
instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 });
|
||||
}
|
||||
}, [trails, pins]);
|
||||
|
||||
/* Leaflet measures its container once, at construction. Inside a drawer or a
|
||||
tab the container is often zero-height at that moment, and the map renders
|
||||
as a grey strip with one tile in the corner until something resizes the
|
||||
window. Re-measuring whenever the box changes size fixes it for good,
|
||||
including when the drawer animates open. */
|
||||
useEffect(() => {
|
||||
const node = host.current;
|
||||
const instance = map.current;
|
||||
if (!node || !instance || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => instance.invalidateSize());
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="trail-map" style={{ height }}>
|
||||
<div ref={host} className="trail-map-canvas" />
|
||||
{isEmpty ? <div className="trail-map-empty">{emptyNote}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct, legible line colours.
|
||||
*
|
||||
* Hand-picked rather than generated from a hue wheel: evenly spaced hues put
|
||||
* two yellows next to each other on an OSM tile and both vanish. These are all
|
||||
* dark enough to read over map detail and different enough to tell apart at the
|
||||
* width of a polyline.
|
||||
*/
|
||||
export const TRAIL_COLOURS = [
|
||||
'#662582',
|
||||
'#0f8a5f',
|
||||
'#c2410c',
|
||||
'#1d4ed8',
|
||||
'#b91c1c',
|
||||
'#0e7490',
|
||||
'#7c2d12',
|
||||
'#4d7c0f',
|
||||
] as const;
|
||||
|
||||
export function trailColour(index: number): string {
|
||||
return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string;
|
||||
}
|
||||
|
||||
function formatPinPopup(pin: MapPin): string {
|
||||
const p = pin.popup;
|
||||
if (p) {
|
||||
if (p.items && p.items.length > 1) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">📦</span>
|
||||
<span class="map-popup-id">${p.items.length} Orders Here</span>
|
||||
</div>
|
||||
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar">🏢</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.customer || 'Multiple Drops')}</span>
|
||||
<span class="map-popup-address">${escapeHtml(p.address || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-multi-list">
|
||||
${p.items.slice(0, 4).map(item => `
|
||||
<div class="map-popup-item-row">
|
||||
<span class="map-popup-item-id">#${escapeHtml(item.orderid)}</span>
|
||||
<span class="map-popup-item-status">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${p.items.length > 4 ? `<div class="map-popup-item-more">+${p.items.length - 4} more orders</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-footer">
|
||||
<div class="map-popup-rider">
|
||||
<span class="map-popup-rider-icon">🛵</span>
|
||||
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Assigned')}</span>
|
||||
</div>
|
||||
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (p.orderId) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">📦</span>
|
||||
<span class="map-popup-id">#${escapeHtml(p.orderId)}</span>
|
||||
</div>
|
||||
${p.step ? `<span class="map-popup-step">Stop #${escapeHtml(String(p.step))}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar">${escapeHtml((p.customer || 'C').charAt(0).toUpperCase())}</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.customer || 'Customer')}</span>
|
||||
<span class="map-popup-address">${escapeHtml(p.address || 'Delivery Address')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status || 'Pending')}</span>
|
||||
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-footer">
|
||||
<div class="map-popup-rider">
|
||||
<span class="map-popup-rider-icon">🛵</span>
|
||||
<span class="map-popup-rider-name">${escapeHtml(p.rider || 'Rider')}</span>
|
||||
</div>
|
||||
<div class="map-popup-amount">${escapeHtml(p.amount || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const isStoreHub = p.subtitle?.includes('Hub') || p.title?.includes('Hub') || p.title?.includes('R mart') || pin.shape === 'square';
|
||||
|
||||
if (isStoreHub) {
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">🏪</span>
|
||||
<span class="map-popup-id">Store Hub</span>
|
||||
</div>
|
||||
<span class="map-popup-step" style="background: #662582;">Origin</span>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-body">
|
||||
<div class="map-popup-customer-row">
|
||||
<div class="map-popup-avatar" style="background: rgba(102, 37, 130, 0.12); color: #662582; font-size: 13px;">🏪</div>
|
||||
<div class="map-popup-cust-details">
|
||||
<span class="map-popup-customer">${escapeHtml(p.title || 'R mart')}</span>
|
||||
<span class="map-popup-subtitle" style="font-size: 11px; color: #64748b; font-weight: 500;">${escapeHtml(p.subtitle || 'Fulfillment & Dispatch Hub')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-address-box" style="display: flex; align-items: flex-start; gap: 5px; background: #f8fafc; padding: 6px 8px; border-radius: 6px; border: 1px solid #e2e8f0; font-size: 11px; color: #475569; line-height: 1.35;">
|
||||
<span style="font-size: 12px; flex-shrink: 0; line-height: 1.2;">📍</span>
|
||||
<span>${escapeHtml(p.address || 'RS Puram Main Rd, D.B. Road, Coimbatore - 641002')}</span>
|
||||
</div>
|
||||
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status || 'Active Origin Depot')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<div class="map-popup-tag">
|
||||
<span class="map-popup-badge-icon">🛵</span>
|
||||
<span class="map-popup-id">${escapeHtml(p.title || pin.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
${p.subtitle ? `<div class="map-popup-customer">${escapeHtml(p.subtitle)}</div>` : ''}
|
||||
${p.address ? `<div class="map-popup-address">${escapeHtml(p.address)}</div>` : ''}
|
||||
${p.status ? `
|
||||
<div class="map-popup-status-bar">
|
||||
<span class="map-popup-status-pill" style="--pill-color: ${escapeAttr(p.statusColor || '#662582')}">
|
||||
<span class="map-popup-status-dot"></span>
|
||||
<span class="map-popup-status-text">${escapeHtml(p.status)}</span>
|
||||
${p.time ? `<span class="map-popup-time">· ${escapeHtml(p.time)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Fallback for default pins
|
||||
return `
|
||||
<div class="map-popup-card">
|
||||
<div class="map-popup-header">
|
||||
<span class="map-popup-id">${escapeHtml(pin.label)}</span>
|
||||
</div>
|
||||
<div class="map-popup-body">
|
||||
${(pin.lines ?? []).map(line => `<div class="map-popup-line">${escapeHtml(line)}</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Popup and icon HTML is a string, so a rider named `<b>` must not become markup. */
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replace(/["'<>]/g, '');
|
||||
}
|
||||
140
src/components/kpiCard.css
Normal file
@@ -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
@@ -0,0 +1,141 @@
|
||||
/* ══ The panel — the console's one card ════════════════════════════════════
|
||||
Three zones: a header band that says what this is and how much of it there
|
||||
is, a body, and a footer band that says where you are in it. See the note at
|
||||
the top of `Panel.tsx` for why the card grew an anatomy rather than a nicer
|
||||
outline.
|
||||
|
||||
Every size here comes from the card tokens in `index.css`. */
|
||||
|
||||
.panel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border: var(--card-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--card-bg);
|
||||
box-shadow: var(--card-shadow);
|
||||
/* The clip belongs on the element that owns the radius, or the header band's
|
||||
tint squares off the top two corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* For a grid row of panels that should finish level with each other. */
|
||||
.panel-card[data-filled='true'] { height: 100%; }
|
||||
|
||||
/* ── Header band ────────────────────────────────────────────────────────── */
|
||||
/* Tinted rather than white, so the band reads as chrome and the rows below it
|
||||
read as data. A white header separated only by a rule is the same surface
|
||||
twice, which is how a heading ends up looking like a first table row. */
|
||||
.panel-card-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 48px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-bottom: var(--card-border);
|
||||
}
|
||||
|
||||
.panel-card-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* An EYEBROW, not a heading. 11px caps: this labels the panel, and a 20px
|
||||
title inside a card competes with the page's own title above it — the two
|
||||
stacked is what made every screen say its subject twice. */
|
||||
.panel-card-title {
|
||||
margin: 0;
|
||||
font: 600 11px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The count, in its own chip. Tabular, because it changes as a filter narrows
|
||||
and a figure that shifts sideways while you read it is hard to trust. */
|
||||
.panel-card-count {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-sunken);
|
||||
font: 600 11.5px/1.45 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.panel-card-count-label {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.panel-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.panel-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel-card-body[data-pad='md'] { padding: 16px; }
|
||||
|
||||
/* ── Footer band ────────────────────────────────────────────────────────── */
|
||||
|
||||
.panel-card-foot {
|
||||
flex: none;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-surface-subtle);
|
||||
border-top: var(--card-border);
|
||||
}
|
||||
|
||||
/* The pager hides itself on a single page — it returns null — but the band
|
||||
around it is rendered by the parent, which cannot see that. Without this a
|
||||
short table carried a 44px strip of tinted chrome saying nothing.
|
||||
`:has` collapses the band when its child rendered no DOM at all. */
|
||||
.panel-card-foot:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The pager's own wrapper adds padding meant for sitting loose under a table.
|
||||
Inside the band the band supplies it. */
|
||||
.panel-card-foot .table-pager {
|
||||
padding: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
/* ── A table sitting flush in the body ──────────────────────────────────────
|
||||
The table brings its own horizontal scroll and its own header row. What it
|
||||
must not bring is a rounded corner or an outer border — the panel owns both,
|
||||
and a table with its own edge inside a panel is the double line this design
|
||||
is meant to remove. */
|
||||
.panel-card-body > .table-scroll {
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Narrow ─────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.panel-card-head { padding: 8px 12px; }
|
||||
.panel-card-foot { padding: 8px 12px; }
|
||||
.panel-card-body[data-pad='md'] { padding: 12px; }
|
||||
/* Actions drop to their own line rather than squeezing the title out. */
|
||||
.panel-card-actions { margin-left: 0; width: 100%; }
|
||||
}
|
||||
82
src/components/searchInput.css
Normal file
@@ -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; }
|
||||
}
|
||||
824
src/components/shell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1009
src/components/shell/AssistantPanel.tsx
Normal file
131
src/components/shell/DateScope.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import {
|
||||
DateRangePicker,
|
||||
presetRange,
|
||||
type RangePreset,
|
||||
} from '@/features/store-admin/DateRangePicker';
|
||||
|
||||
/**
|
||||
* The console's date filter, held once for the whole workspace.
|
||||
*
|
||||
* It sits in the top bar beside the profile, not on the page, and every page
|
||||
* reads it from here — the same relationship `BranchScope` already has to the
|
||||
* branch picker beside the logo. The two scopes now work the same way: the two
|
||||
* questions every page is asked, "which shop" and "when", are answered once in
|
||||
* the chrome rather than re-answered on each screen.
|
||||
*
|
||||
* ── What this changes about the pages ───────────────────────────────────────
|
||||
*
|
||||
* The range survives navigation. Setting March on Sales and clicking through to
|
||||
* Reports shows March, which is what somebody looking into a month actually
|
||||
* wants and is the whole reason for lifting it. It also means the live boards —
|
||||
* Console, Counters, Terminals — no longer force themselves back to today; they
|
||||
* follow the shared range like everything else. That is the trade the move
|
||||
* makes, and it is the right one, but it IS a change: those three used to be
|
||||
* pinned to the day whatever else you had chosen.
|
||||
*/
|
||||
|
||||
export interface DateScopeValue {
|
||||
preset: RangePreset;
|
||||
/**
|
||||
* What the PAGES read — always a real window.
|
||||
*
|
||||
* When nobody has picked anything this is the default week, not an empty
|
||||
* object. Left empty, every read on the console became unbounded and each
|
||||
* part of a page bounded it differently: the Console's chart drew however
|
||||
* many days the last 500 orders happened to span (nine, in practice) while
|
||||
* the KPI tiles beside it totalled all of them, and the counter figures came
|
||||
* from a POS summary with no window at all. One page, three answers.
|
||||
*/
|
||||
range: DateRange;
|
||||
/**
|
||||
* What the PICKER shows — empty until somebody chooses.
|
||||
*
|
||||
* This is the half that keeps the filter unselected on arrival. The two are
|
||||
* separate on purpose: "no filter has been set" is a fact about the control,
|
||||
* and "which week are we looking at" is a fact about the data, and collapsing
|
||||
* them into one value is what forced a choice between a filter nobody set and
|
||||
* a page with no window.
|
||||
*/
|
||||
chosen: DateRange;
|
||||
set: (preset: RangePreset, range: DateRange) => void;
|
||||
/** Back to no filter at all — what an empty state offers as a way out. */
|
||||
clear: () => void;
|
||||
/** True when the USER has narrowed the page, not merely that a window exists. */
|
||||
isFiltered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window the console shows when nobody has picked one.
|
||||
*
|
||||
* A WEEK — while the picker still shows nothing selected. Those are two
|
||||
* separate statements and both are deliberate.
|
||||
*
|
||||
* The filter opens unselected because the console used to narrow every page
|
||||
* before anyone asked: somebody signing in to see how trade is going was shown
|
||||
* a slice with no sign that it was one, and the range then followed them across
|
||||
* the whole console.
|
||||
*
|
||||
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
|
||||
* capped only by `pagesize`, and each part of a page then bounded itself
|
||||
* differently — the Console's chart drew however many days the last 500 orders
|
||||
* happened to span (nine), the KPI tiles beside it totalled all of those
|
||||
* orders, and the counter figures came from a POS summary with no window
|
||||
* whatsoever. One page, three different answers to "when".
|
||||
*
|
||||
* A default window is not a filter. Nothing is hidden from the reader, the
|
||||
* control is empty, and one choice replaces it.
|
||||
*/
|
||||
const DEFAULT_WINDOW: RangePreset = 'week';
|
||||
|
||||
const DateScopeContext = createContext<DateScopeValue | null>(null);
|
||||
|
||||
export function DateScopeProvider({ children }: { children: ReactNode }) {
|
||||
/* What the user picked. Empty until they do — this is what the picker shows. */
|
||||
const [preset, setPreset] = useState<RangePreset>('custom');
|
||||
const [chosen, setChosen] = useState<DateRange>({});
|
||||
|
||||
const value = useMemo<DateScopeValue>(() => {
|
||||
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
|
||||
return {
|
||||
preset,
|
||||
chosen,
|
||||
/* The window the pages actually read: what was picked, or the default
|
||||
week. Resolved here, once, so every read on a page shares it — the
|
||||
orders, the POS summaries and the chart cannot end up describing
|
||||
different spans. */
|
||||
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
|
||||
set: (nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setChosen(nextRange);
|
||||
},
|
||||
clear: () => {
|
||||
setPreset('custom');
|
||||
setChosen({});
|
||||
},
|
||||
isFiltered: hasChoice,
|
||||
};
|
||||
}, [preset, chosen]);
|
||||
|
||||
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared range.
|
||||
*
|
||||
* Throws outside the provider rather than inventing a local range: a page that
|
||||
* silently filtered on its own dates while the bar showed something else would
|
||||
* be the exact confusion this exists to remove.
|
||||
*/
|
||||
export function useDateScope(): DateScopeValue {
|
||||
const value = useContext(DateScopeContext);
|
||||
if (!value) throw new Error('useDateScope must be used inside a DateScopeProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The control itself. Rendered once, in the top bar. */
|
||||
export function DateScopePicker() {
|
||||
const dates = useDateScope();
|
||||
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
|
||||
}
|
||||
91
src/components/shell/assistantContext.test.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
});
|
||||
280
src/components/shell/assistantContext.ts
Normal 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 };
|
||||
}
|
||||
21
src/components/shell/assistantScope.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* What Nearle Buddy is currently answering about.
|
||||
*
|
||||
* The panel names its own scope in its heading — "Across your branches" — and
|
||||
* that heading is a promise about which data an answer would draw on. When an
|
||||
* admin narrows to one branch, or a store user opens the console at all, the
|
||||
* promise is wrong: the board beneath says R mart and the panel above still
|
||||
* says every branch.
|
||||
*
|
||||
* So the SCOPE is published here and the panel reads it. Nothing else about
|
||||
* Buddy changes — not its width, its controls, its prompts or its placement.
|
||||
* A page that has no branch scope publishes nothing and the panel keeps the
|
||||
* per-route wording it has always used.
|
||||
*/
|
||||
export const AssistantScopeContext = createContext<string | undefined>(undefined);
|
||||
|
||||
export function useAssistantScope(): string | undefined {
|
||||
return useContext(AssistantScopeContext);
|
||||
}
|
||||
94
src/components/shell/assistantWidth.ts
Normal 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 };
|
||||
}
|
||||
70
src/components/shell/composerState.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { composerHint, composerNote } from './AssistantPanel';
|
||||
|
||||
/*
|
||||
The panel carried a hardcoded sentence under the composer — "Not connected yet
|
||||
— there is no assistant service behind this panel." — written before there was
|
||||
an API behind it and never removed once there was. It had no condition on it, so
|
||||
it rendered in every state on every page, and it said Buddy was off while the
|
||||
placeholder three lines above said "Ask about this page".
|
||||
|
||||
That cost real debugging time: the panel was read as evidence the backend had no
|
||||
model, on a deployment whose backend was answering questions.
|
||||
|
||||
So both lines are asserted together here. The rule is that they agree.
|
||||
*/
|
||||
|
||||
const CHECKING = null;
|
||||
const LIVE = true;
|
||||
const OFF = false;
|
||||
|
||||
test('the note and the placeholder never disagree', () => {
|
||||
// Whenever the note claims Buddy is not connected, the placeholder must say
|
||||
// the same, and vice versa. Disagreeing is the bug, in either direction.
|
||||
for (const available of [CHECKING, LIVE, OFF]) {
|
||||
for (const hasAgent of [true, false]) {
|
||||
const note = composerNote(available, hasAgent);
|
||||
const hint = composerHint(available, hasAgent, false);
|
||||
|
||||
assert.equal(
|
||||
note.startsWith('Not connected yet'),
|
||||
hint === 'Not connected yet',
|
||||
`note ${JSON.stringify(note)} disagrees with placeholder ${JSON.stringify(hint)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a working assistant does not announce that it is disconnected', () => {
|
||||
// The regression itself.
|
||||
const note = composerNote(LIVE, true);
|
||||
|
||||
assert.ok(!note.includes('Not connected'), `a live panel still said: ${note}`);
|
||||
assert.equal(composerHint(LIVE, true, false), 'Ask about this page');
|
||||
});
|
||||
|
||||
test('an unconfigured deployment says so', () => {
|
||||
assert.match(composerNote(OFF, true), /Not connected yet/);
|
||||
assert.match(composerNote(OFF, false), /Not connected yet/);
|
||||
});
|
||||
|
||||
test('a page with no agent says it is the page, not the deployment', () => {
|
||||
// Two different facts, and only one of them is something the person can do
|
||||
// anything about: they can move to a page that has an assistant.
|
||||
const note = composerNote(LIVE, false);
|
||||
|
||||
assert.ok(!note.includes('Not connected'));
|
||||
assert.match(note, /no assistant for this page/i);
|
||||
});
|
||||
|
||||
test('nothing is claimed while the check is still in flight', () => {
|
||||
// A sentence that appears and is replaced 200ms later reads as a flicker, and
|
||||
// the one it used to show was the wrong one.
|
||||
assert.equal(composerNote(CHECKING, true), '');
|
||||
assert.equal(composerHint(CHECKING, true, false), 'Checking…');
|
||||
});
|
||||
|
||||
test('asking outranks everything in the placeholder', () => {
|
||||
assert.equal(composerHint(LIVE, true, true), 'Thinking…');
|
||||
});
|
||||
165
src/components/tabBar.css
Normal file
@@ -0,0 +1,165 @@
|
||||
/* ══ The view switcher ═════════════════════════════════════════════════════
|
||||
A segmented control: a recessed track, and the selected view raised out of
|
||||
it onto the card surface. See the note in `TabBar.tsx` for why this replaced
|
||||
six private copies of a transparent pill. */
|
||||
|
||||
.tabbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: var(--card-radius-sm);
|
||||
background: var(--color-surface-sunken);
|
||||
/* The track is a control, so it takes the control radius rather than the
|
||||
card one, and it never stretches: a segmented control that fills the page
|
||||
width puts its two tabs at either end of the screen. */
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tabbar-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--card-radius-sm) - 3px);
|
||||
background: transparent;
|
||||
color: var(--color-ink-3);
|
||||
font: 500 13px/1 var(--font-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
/* Hover is a hint of the track's own surface, not the selected state — an
|
||||
unselected tab that looks selected on hover is the oldest bug in this
|
||||
control. */
|
||||
.tabbar-tab:hover:not([aria-selected='true']) {
|
||||
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
/* Raised out of the track: the card surface, a hairline and the same 1px
|
||||
micro-lift every card in the console carries. That is what makes it read as
|
||||
sitting ON the track rather than tinted inside it. */
|
||||
.tabbar-tab[aria-selected='true'] {
|
||||
background: var(--card-bg);
|
||||
border-color: var(--color-line);
|
||||
box-shadow: var(--card-shadow);
|
||||
color: var(--color-ink-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabbar-tab:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The icon carries the brand on the selected tab and nothing on the others, so
|
||||
colour marks the selection once rather than three times. */
|
||||
.tabbar-icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.tabbar-tab[aria-selected='true'] .tabbar-icon { color: var(--color-brand); }
|
||||
|
||||
.tabbar-label { min-width: 0; }
|
||||
|
||||
/* ── The count ──────────────────────────────────────────────────────────────
|
||||
A figure, not a badge.
|
||||
|
||||
Three of the six copies filled this with `--color-warning` — the amber this
|
||||
console uses for something needing attention — so a tab reading "Orders 85"
|
||||
announced eighty-five problems. It is set as a quiet tabular number that
|
||||
takes the brand only on the selected tab, which is the one whose count the
|
||||
reader is actually acting on. */
|
||||
.tabbar-count {
|
||||
flex: none;
|
||||
font: 600 11.5px/1 var(--font-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-4);
|
||||
padding: 3px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-ink-4) 10%, transparent);
|
||||
}
|
||||
.tabbar-tab[aria-selected='true'] .tabbar-count {
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
/* ── Live dot ───────────────────────────────────────────────────────────────
|
||||
For a view that updates by itself. Green rather than the brand, because it
|
||||
reports a STATE — the data is moving — and the brand in this console carries
|
||||
action and identity. It keeps its colour on the selected tab for the same
|
||||
reason. */
|
||||
.tabbar-live {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-success, #1f9d55);
|
||||
animation: tabbar-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes tabbar-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
/* A pulse is decoration; the dot itself is the information, so it stays. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabbar-live { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Group divider ──────────────────────────────────────────────────────────
|
||||
A hairline inside the track, for a tab that belongs to a different family
|
||||
from the one before it — Counter sales beside the app's own Orders and
|
||||
Deliveries. Drawn as a margin plus a border so it sits in the gap rather
|
||||
than on either tab. */
|
||||
.tabbar-tab[data-group-start='true'] {
|
||||
margin-left: 9px;
|
||||
position: relative;
|
||||
}
|
||||
.tabbar-tab[data-group-start='true']::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: var(--color-line);
|
||||
}
|
||||
|
||||
/* ── Secondary strip ────────────────────────────────────────────────────────
|
||||
Under a primary switcher — a status filter, say. Smaller, and with no track
|
||||
of its own, so two rows of tabs do not read as two controls of equal rank. */
|
||||
.tabbar[data-size='sm'] {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-tab {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12.5px;
|
||||
border-radius: var(--card-radius-sm);
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] {
|
||||
background: var(--color-brand-tint);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-tab[aria-selected='true'] .tabbar-count {
|
||||
background: color-mix(in oklab, var(--color-brand) 16%, transparent);
|
||||
}
|
||||
.tabbar[data-size='sm'] .tabbar-icon { display: none; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tabbar-tab { padding: 0 10px; gap: 6px; }
|
||||
.tabbar-icon { display: none; }
|
||||
}
|
||||
24
src/components/tablePager.css
Normal file
@@ -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
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* The map's frame and its pins.
|
||||
*
|
||||
* Leaflet ships its own stylesheet for the tiles, controls and popups; this
|
||||
* covers only what the console adds — the container, the divIcon pins and the
|
||||
* empty state — plus the two places leaflet's defaults clash with the console.
|
||||
*/
|
||||
|
||||
.trail-map {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.trail-map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Sits over the tiles rather than replacing them: an empty map still shows the
|
||||
region, so "no positions here" reads as an absence in a real place instead of
|
||||
a component that failed to load. */
|
||||
.trail-map-empty {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 500;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The divIcon's own box — leaflet gives it a white background and a border by
|
||||
default, which would frame every pin in a small white square. */
|
||||
.trail-pin-wrap {
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.trail-pin {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--pin, var(--color-brand));
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgb(15 23 42 / 45%);
|
||||
}
|
||||
|
||||
/* A place, not an event. Squared off and a touch larger so a shop reads as the
|
||||
thing the round starts from rather than as one more drop on it. */
|
||||
.trail-pin[data-shape='square'] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* A last-known position, not a live one. Hollow, so the difference between
|
||||
"here now" and "here when they last reported" is visible on the map itself
|
||||
and not only in the popup. */
|
||||
.trail-pin[data-faded='true'] {
|
||||
background: transparent;
|
||||
border-color: var(--pin, var(--color-brand));
|
||||
border-width: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Leaflet's controls and popups default to its own font stack and a blue link
|
||||
colour; both look foreign next to the rest of the console. */
|
||||
.trail-map .leaflet-container {
|
||||
font: inherit;
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
/* ── Leaflet Modern Popup Card ────────────────────────────────────────────── */
|
||||
.trail-map .leaflet-popup {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-content-wrapper {
|
||||
padding: 0 !important;
|
||||
border-radius: var(--card-radius) !important;
|
||||
background: var(--card-bg) !important;
|
||||
/* The console's hairline, not a near-miss of it. The shadow STAYS: this one
|
||||
genuinely floats, over a map, which is what a shadow is for. */
|
||||
border: var(--card-border) !important;
|
||||
box-shadow: 0 16px 36px -4px rgba(15, 23, 42, 0.18), 0 4px 12px rgba(102, 37, 130, 0.1) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
line-height: 1.4 !important;
|
||||
font-family: inherit !important;
|
||||
color: #1e293b !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-tip-container {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-tip {
|
||||
background: #ffffff !important;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15) !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-close-button {
|
||||
top: 7px !important;
|
||||
right: 7px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
border-radius: 50% !important;
|
||||
background: rgba(241, 245, 249, 0.85) !important;
|
||||
color: #64748b !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
text-decoration: none !important;
|
||||
transition: all 0.15s ease !important;
|
||||
z-index: 10 !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-popup-close-button:hover {
|
||||
background: #fee2e2 !important;
|
||||
color: #ef4444 !important;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Card Content Structure */
|
||||
.map-popup-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
max-width: 275px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.map-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 7px;
|
||||
background: linear-gradient(135deg, rgba(102, 37, 130, 0.06), rgba(146, 85, 171, 0.03));
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.map-popup-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.map-popup-badge-icon {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.map-popup-id {
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: #662582;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.map-popup-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #662582;
|
||||
color: #ffffff;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.map-popup-body {
|
||||
padding: 10px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-popup-customer-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.map-popup-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(102, 37, 130, 0.1);
|
||||
color: #662582;
|
||||
font-weight: 800;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-popup-cust-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-popup-customer {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-popup-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.map-popup-address {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.map-popup-address-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 6px 9px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.map-popup-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.map-popup-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--pill-color, #662582) 10%, #ffffff);
|
||||
border: 1px solid color-mix(in srgb, var(--pill-color, #662582) 25%, transparent);
|
||||
}
|
||||
|
||||
.map-popup-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--pill-color, #662582);
|
||||
}
|
||||
|
||||
.map-popup-status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--pill-color, #662582);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.map-popup-time {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.map-popup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 12px 8px;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.map-popup-rider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.map-popup-rider-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.map-popup-amount {
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.map-popup-multi-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 6px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.map-popup-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.map-popup-item-id {
|
||||
font-weight: 700;
|
||||
color: #662582;
|
||||
}
|
||||
|
||||
.map-popup-item-status {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.map-popup-item-more {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.map-popup-line {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.trail-map .leaflet-control-attribution {
|
||||
font-size: 10px;
|
||||
background: color-mix(in oklab, var(--color-surface) 85%, transparent);
|
||||
}
|
||||
|
||||
.trail-map .leaflet-control-attribution a {
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
/* A numbered pin carries the stop's place in the round. Bigger than a plain
|
||||
dot because two digits have to stay legible over map detail, and the white
|
||||
ring is what keeps them readable against a dark tile. */
|
||||
.trail-pin[data-numbered='true'] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 1px rgb(15 23 42 / 45%);
|
||||
}
|
||||
|
||||
/* Another round is focused. Faded rather than hidden — a neighbouring rider's
|
||||
stops are still context, and hiding them makes two overlapping rounds
|
||||
impossible to compare. */
|
||||
.trail-pin[data-muted='true'] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* The round's first stop. Ringed rather than recoloured, so it keeps its own
|
||||
status colour while still reading as the start. */
|
||||
.trail-pin[data-start='true'] {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--pin, var(--color-brand)),
|
||||
0 1px 4px rgb(15 23 42 / 45%);
|
||||
}
|
||||
104
src/components/usePaged.test.ts
Normal file
@@ -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
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Paging for a table, over rows already in hand.
|
||||
*
|
||||
* ── Why client-side ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* Fiesta pages properly — `pageno` genuinely shifts the window, verified
|
||||
* against `getorders`. What it does NOT return is a total: the envelope carries
|
||||
* `code`, `details`, `message`, `status` and nothing else. So a server-paged
|
||||
* table could offer next/prev and never honestly say "of 12 pages", and could
|
||||
* not tell a last page from an empty one until it fetched it.
|
||||
*
|
||||
* Every list here already fetches a bounded window (200 rows, 500 for the
|
||||
* customer book) and renders all of it. Paging that window client-side gives a
|
||||
* real total, real page numbers, instant page turns, and works identically for
|
||||
* the tables that have no server paging at all — grouped dispatch stops,
|
||||
* reports, anything derived. When a table outgrows its fetch window the answer
|
||||
* is to raise the window or move that ONE table to cursor paging with
|
||||
* `hasMore`, not to make every table pretend.
|
||||
*
|
||||
* ── What this hook is actually for ──────────────────────────────────────────
|
||||
*
|
||||
* The slicing is the trivial part. The part worth having in one place, tested,
|
||||
* is what happens when the rows underneath change — which is where hand-rolled
|
||||
* paging goes wrong: you filter down to three results while on page 5 and the
|
||||
* table renders empty with no way back.
|
||||
*/
|
||||
|
||||
export interface Paged<T> {
|
||||
/** 1-based, matching the design system's Pagination. */
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
pageSize: number;
|
||||
setPageSize: (size: number) => void;
|
||||
/** Just this page's rows. */
|
||||
rows: T[];
|
||||
/** Every row, before slicing. */
|
||||
total: number;
|
||||
totalPages: number;
|
||||
/** 1-based inclusive range on screen, for "showing 21–40 of 96". Zero when empty. */
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fifteen rows, everywhere, until somebody says otherwise.
|
||||
*
|
||||
* Every table on the console takes this — no call site overrides it — so this
|
||||
* constant IS the console's answer to "how long is a page", and changing it
|
||||
* here changes all of them at once. That is the point: a default that drifts per
|
||||
* screen makes the pager something to re-read on every page rather than
|
||||
* something learnt once.
|
||||
*
|
||||
* It is a default, not a limit. The selector in `TablePager` offers the sizes
|
||||
* below and `setPageSize` keeps whatever is chosen for as long as the table is
|
||||
* mounted, so a person working through a long list can widen it and stay widened.
|
||||
*/
|
||||
export const DEFAULT_PAGE_SIZE = 15;
|
||||
|
||||
/**
|
||||
* The choices offered in the page-size selector.
|
||||
*
|
||||
* 15 is here because the default has to be selectable — a selector whose current
|
||||
* value is not one of its options has nothing to show as chosen. 10 stays: it is
|
||||
* the "show me less" end, and the default moving up is no reason to take it away.
|
||||
*/
|
||||
export const PAGE_SIZES = [10, 15, 25, 50, 100];
|
||||
|
||||
export function usePaged<T>(
|
||||
rows: readonly T[],
|
||||
options: {
|
||||
pageSize?: number;
|
||||
/**
|
||||
* Changing this sends the table back to page 1.
|
||||
*
|
||||
* Clamping alone is not enough. Switching branch, day or status tab can
|
||||
* hand back a DIFFERENT set of rows that happens to be at least as long as
|
||||
* the old one — nothing to clamp — and the operator is left reading page 4
|
||||
* of something they just started looking at. Pass whatever identifies the
|
||||
* query: a day, a branch id, a status, or a template string of several.
|
||||
*/
|
||||
resetKey?: string | number;
|
||||
} = {},
|
||||
): Paged<T> {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
||||
|
||||
const total = rows.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const { resetKey } = options;
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [resetKey]);
|
||||
|
||||
/*
|
||||
Clamped on the way out as well as reset above.
|
||||
|
||||
A row set can shrink under a page without the query changing at all — a
|
||||
delivery gets marked delivered and leaves the tab, someone types another
|
||||
letter into the search. Reading `page` directly would then slice past the end
|
||||
and render an empty table on page 5 of 2, which looks like the data
|
||||
disappeared. Deriving the safe page rather than setting state in an effect
|
||||
also means the correct rows render on the FIRST pass, with no empty frame in
|
||||
between.
|
||||
*/
|
||||
const safePage = Math.min(page, totalPages);
|
||||
|
||||
const pageRows = useMemo(
|
||||
() => rows.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[rows, safePage, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
page: safePage,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize: (size: number) => {
|
||||
// Back to the first page: keeping the number would land you somewhere
|
||||
// unrelated, since page 4 of 10-per-page is page 1 of 50-per-page.
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
},
|
||||
rows: pageRows,
|
||||
total,
|
||||
totalPages,
|
||||
from: total === 0 ? 0 : (safePage - 1) * pageSize + 1,
|
||||
to: Math.min(safePage * pageSize, total),
|
||||
};
|
||||
}
|
||||
60
src/components/useSelection.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
|
||||
/*
|
||||
The selection logic, lifted out of the hook so it can be tested without React.
|
||||
|
||||
The rule that matters: a bulk action must only ever touch rows the person could
|
||||
see when they chose them. Approving stock moves it, so acting on a row hidden
|
||||
behind a filter is not a cosmetic bug.
|
||||
*/
|
||||
|
||||
function chosenOf(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||
return visible.filter((id) => picked.has(id));
|
||||
}
|
||||
|
||||
function afterToggleAll(visible: readonly number[], picked: ReadonlySet<number>) {
|
||||
const next = new Set(picked);
|
||||
const everyVisibleChosen = visible.length > 0 && visible.every((id) => next.has(id));
|
||||
for (const id of visible) {
|
||||
if (everyVisibleChosen) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
test('a bulk action never touches a row that was filtered away', () => {
|
||||
// Ticked while the list showed everything, then the list was narrowed.
|
||||
const picked = new Set([1, 2, 3]);
|
||||
assert.deepEqual(chosenOf([2], picked), [2]);
|
||||
});
|
||||
|
||||
test('narrowing and widening again does not untick the work', () => {
|
||||
// The hidden ids stay in the set; they are simply not acted on while hidden.
|
||||
const picked = new Set([1, 2, 3]);
|
||||
assert.deepEqual(chosenOf([1, 2, 3], picked), [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('select all covers only what is on screen', () => {
|
||||
const next = afterToggleAll([2, 3], new Set());
|
||||
assert.deepEqual([...next].sort(), [2, 3]);
|
||||
});
|
||||
|
||||
test('select all a second time clears exactly what it added', () => {
|
||||
const picked = afterToggleAll([2, 3], new Set([9]));
|
||||
const cleared = afterToggleAll([2, 3], picked);
|
||||
// 9 was chosen elsewhere and is not on screen, so it survives.
|
||||
assert.deepEqual([...cleared], [9]);
|
||||
});
|
||||
|
||||
test('select all on an empty list does nothing', () => {
|
||||
assert.equal(afterToggleAll([], new Set()).size, 0);
|
||||
});
|
||||
|
||||
test('the header is only fully ticked when every visible row is', () => {
|
||||
const visible = [1, 2];
|
||||
const partial = chosenOf(visible, new Set([1]));
|
||||
assert.equal(partial.length === visible.length, false);
|
||||
const full = chosenOf(visible, new Set([1, 2]));
|
||||
assert.equal(full.length === visible.length, true);
|
||||
});
|
||||
88
src/components/useSelection.ts
Normal file
@@ -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
@@ -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;
|
||||
946
src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
603
src/features/catalogue/CatalogueBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
src/features/catalogue/CatalogueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
src/features/catalogue/CatalogueDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/features/catalogue/CatalogueSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/features/catalogue/tenantCategories.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The category the customer app browses.
|
||||
*
|
||||
* A constant because it is one on the app side too — its browse screen asks for
|
||||
* `categoryid: 2` — and because a product filed anywhere else is invisible to
|
||||
* shoppers rather than merely misfiled.
|
||||
*
|
||||
* Measured, not assumed: every tenant on the platform that has products reports
|
||||
* category 2 and nothing else, and category 2 carries the ten real retail
|
||||
* subcategories.
|
||||
*
|
||||
* It survives as a LAST RESORT only. Nothing picks a category any longer: every
|
||||
* product is classified by `productCategory.ts` and the name exchanged for this
|
||||
* merchant's id by `resolvecategories`. This is what a row falls back to when
|
||||
* that call cannot be made at all — better a product on the shelf under the
|
||||
* aisle the app browses than a product filed under 0, which no browse query
|
||||
* returns.
|
||||
*
|
||||
* What used to live beside it, `categoryOptionsFor`, is gone with the pickers
|
||||
* it fed. It offered a tenant's own categories and this constant as a floor,
|
||||
* and its floor was the visible symptom: a merchant with no list yet was shown
|
||||
* "General (the category the app shows)", chose it because it was the only
|
||||
* entry, and every product they imported arrived as "Category 2".
|
||||
*/
|
||||
export const APP_BROWSE_CATEGORY = 2;
|
||||