backend integration started

This commit is contained in:
2026-08-26 18:37:28 +05:30
parent 517a99d577
commit 3545819be8
50 changed files with 3461 additions and 3241 deletions

View File

@@ -8,7 +8,10 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3100",
"typecheck": "tsc --noEmit",
"test": "tsx --test \"src/**/*.test.ts\""
"test": "tsx --test \"src/**/*.test.ts\"",
"contract": "node scripts/contract.mjs",
"db": "node scripts/db.mjs",
"verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\""
},
"dependencies": {
"@astryxdesign/core": "^0.4.5",
@@ -29,6 +32,7 @@
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.3.3",
"tsx": "^4.20.3",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}

434
scripts/contract.mjs Normal file
View File

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

251
scripts/db.mjs Normal file
View File

@@ -0,0 +1,251 @@
/**
* 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));
const ENDPOINT = process.env.HASURA_URL ?? 'https://api.workolik.com/v1/graphql';
/** 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`);
}
/* ── 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(' ')),
};
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',
].join('\n'),
);
process.exit(command ? 1 : 0);
}
await COMMANDS[command]();

View File

@@ -25,7 +25,7 @@ export const catalogueApi = {
* merged, paginated list — the list is never gated behind a brand selector.
*/
products: (query: CatalogueQuery = {}) =>
api.get<CatalogueProduct[]>(`${WEB}/catalogue/getproducts`, {
api.list<CatalogueProduct>(`${WEB}/catalogue/getproducts`, {
brand: query.brand,
category: query.category,
keyword: query.keyword,
@@ -34,11 +34,11 @@ export const catalogueApi = {
}),
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
brands: () => api.get<CatalogueBrand[]>(`${WEB}/catalogue/getbrands`),
brands: () => api.list<CatalogueBrand>(`${WEB}/catalogue/getbrands`),
/** Requires a brand — the backend reads categories from one brand's table. */
categories: (brand: string) =>
api.get<string[]>(`${WEB}/catalogue/getcategories`, { brand }),
api.list<string>(`${WEB}/catalogue/getcategories`, { brand }),
/**
* One catalogue row in full — the fields the import leaves behind
@@ -56,7 +56,7 @@ export const catalogueApi = {
* mixes brands.
*/
importedRefs: (tenantid: number) =>
api.get<CatalogueRef[]>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
api.list<CatalogueRef>(`${WEB}/products/getimportedcatalogueproducts`, { tenantid }),
};
/** Key for the imported-refs lookup. Both halves, always. */

View File

@@ -6,7 +6,6 @@
* changes. Nothing else in the app calls `fetch`.
*/
import { demoResolve, isDemoActive, MISS } from '@/demo';
import type { FiestaEnvelope } from './types';
/**
@@ -16,9 +15,34 @@ import type { FiestaEnvelope } from './types';
*/
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
/** Every console route lives under this prefix. `/mob/*` and `/pos/*` differ. */
/** Every console route lives under this prefix. */
export const WEB = '/live/api/v1/web';
export const POS = '/live/api/v1/pos';
/**
* 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.
@@ -73,17 +97,12 @@ interface RequestOptions {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', params, body, signal } = options;
// Demo mode short-circuits before any network call. In a production build
// `isDemoActive` is a constant `false`, so the bundler removes this branch
// and the fixtures with it.
if (import.meta.env.DEV && isDemoActive()) {
const fixture = await demoResolve(path, params as Record<string, unknown> | undefined);
if (fixture !== MISS) {
// A beat of latency, so loading states are visible while working on them.
await new Promise((resolve) => setTimeout(resolve, 180));
return fixture as T;
}
}
// 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)}`;
@@ -128,7 +147,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
);
}
return envelope.details as T;
// Most handlers put the payload in `details`, but a handful answer with
// `data` instead — `products/getallproducts` and `products/create` among the
// ones the console calls (`productController.go:400,206`). Reading only
// `details` handed those two callers `undefined` with no error anywhere.
return (envelope.details ?? envelope.data) as T;
}
/**
@@ -166,6 +189,26 @@ 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 }),

View File

@@ -43,7 +43,7 @@ export interface CustomerQuery {
export const customersApi = {
list: (query: CustomerQuery) =>
api.get<CustomerInfo[]>(`${WEB}/customers/gettenantcustomers`, {
api.list<CustomerInfo>(`${WEB}/customers/gettenantcustomers`, {
tenantid: query.tenantid,
locationid: query.locationid,
keyword: query.keyword || undefined,

View File

@@ -15,6 +15,7 @@ import type {
OrderRow,
OrderSummary,
PosLocationHealth,
PosTerminalHealth,
PosSalesPage,
PosSalesSummary,
} from './types';
@@ -49,7 +50,7 @@ export const insightsApi = {
* sending 0 silently gives page one rather than an error.
*/
orders: (query: OrderQuery) =>
api.get<OrderRow[]>(`${WEB}/orders/tenant/getorders`, {
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
@@ -73,7 +74,7 @@ export const insightsApi = {
* applocationid/userid/appuserid is present, so `tenantid` is required here.
*/
deliveries: (query: OrderQuery) =>
api.get<DeliveryRow[]>(`${WEB}/deliveries/getdeliveries`, {
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
@@ -89,7 +90,7 @@ export const insightsApi = {
/** Per-branch order totals for one tenant. `tenantid` is required. */
locationSummary: (tenantid: number, range: DateRange = {}) =>
api.get<LocationOrderSummary[]>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
api.list<LocationOrderSummary>(`${WEB}/orders/getlocationsummary`, { tenantid, ...range }),
revenueSummary: (tenantid: number, range: DateRange = {}) =>
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
@@ -153,7 +154,17 @@ export const insightsApi = {
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. */
posHealth: (locationid: number) =>
api.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid }),
/**
* 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 : [])),
};

View File

@@ -21,7 +21,7 @@
* account ends up holding a role that matches nothing.
*/
import { api, POS, WEB } from './client';
import { api, MOB, WEB } from './client';
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
/* ── Back-office staff ───────────────────────────────────────────────────── */
@@ -56,9 +56,16 @@ export const staffApi = {
* 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 the MOB prefix, and that is not a choice: `getstaffs` is registered on
* `/v1/mob/tenants` only (`tenantroutes.go:35`) and has no `/web` twin, so the
* path this used to call did not exist. The old console avoided the question
* by using `users/getallusers`, whose SQL selects no `rolename` at all — it
* had to map role ids client-side, which is the thing the backend warns
* against above.
*/
list: (tenantid: number) =>
api.get<StaffInfo[]>(`${WEB}/tenants/getstaffs`, { tenantid }),
api.list<StaffInfo>(`${MOB}/tenants/getstaffs`, { tenantid }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
@@ -106,9 +113,22 @@ export interface UpdatePosUserRequest {
}
export const posUsersApi = {
/** Till accounts at one outlet. `locationid` is required and singular. */
/**
* 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) =>
api.get<PosUser[]>(`${POS}/getposusers`, { tenantid, locationid }),
api
.get<{ location_id?: number; users?: PosUser[] }>(`${WEB}/tenants/getposusers`, {
tenantid,
locationid,
})
.then((page) => (Array.isArray(page?.users) ? page.users : [])),
/**
* The role picker's source.
@@ -117,7 +137,7 @@ export const posUsersApi = {
* 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.get<PosRole[]>(`${POS}/posroles`),
roles: () => api.list<PosRole>(`${WEB}/tenants/posroles`),
/**
* Create a till account.
@@ -126,13 +146,22 @@ export const posUsersApi = {
* 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>(`${POS}/createposuser`, body),
create: (body: CreatePosUserRequest) => api.post<PosUser>(`${WEB}/tenants/createposuser`, body),
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${POS}/updateposuser`, body),
update: (body: UpdatePosUserRequest) => api.put<PosUser>(`${WEB}/tenants/updateposuser`, body),
/** Shift windows a till account can be put on. */
/**
* Shift windows a till account can be put on.
*
* Wrapped the same way — `{location_id, shifts}` (`posController.go:934-936`).
*/
shifts: (tenantid: number, locationid: number) =>
api.get<StaffShift[]>(`${POS}/getstaffshifts`, { tenantid, locationid }),
api
.get<{ location_id?: number; shifts?: StaffShift[] }>(`${WEB}/tenants/getstaffshifts`, {
tenantid,
locationid,
})
.then((page) => (Array.isArray(page?.shifts) ? page.shifts : [])),
};
/**

View File

@@ -40,24 +40,37 @@ export interface LocationProductQuery {
export const productsApi = {
/** A store's own catalogue — what is actually imported, with live stock. */
locationProducts: (query: LocationProductQuery) =>
api.get<Product[]>(`${WEB}/products/getlocationproducts`, {
api.list<Product>(`${WEB}/products/getlocationproducts`, {
tenantid: query.tenantid,
locationid: query.locationid,
pageno: query.pageno ?? 0,
pagesize: query.pagesize ?? 50,
}),
/**
* 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.
*/
allProducts: (tenantid: number) =>
api.get<Product[]>(`${WEB}/products/getallproducts`, { tenantid }),
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.get<ProductCategory[]>(`${WEB}/products/getproductcategories`, { tenantid }),
api.list<ProductCategory>(`${WEB}/products/getproductcategories`, { tenantid }),
subCategories: (tenantid: number, categoryid: number) =>
api.get<ProductSubCategory[]>(`${WEB}/products/getproductsubcategories`, {
api.list<ProductSubCategory>(`${WEB}/products/getproductsubcategories`, {
tenantid,
categoryid,
}),
@@ -111,7 +124,7 @@ export const productsApi = {
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
*/
tenantCategories: (tenantid: number) =>
api.get<{ categoryid: number; categoryname: string }[]>(
api.list<{ categoryid: number; categoryname: string }>(
`${WEB}/products/gettenantcategories`,
{ tenantid },
),

View File

@@ -70,7 +70,7 @@ export const stockApi = {
}),
requests: (query: StockRequestQuery) =>
api.get<StockRequest[]>(`${WEB}/products/getstockrequests`, {
api.list<StockRequest>(`${WEB}/products/getstockrequests`, {
tenantid: query.tenantid,
locationid: query.locationid,
status: query.status,
@@ -115,7 +115,7 @@ export const stockApi = {
pageno?: number;
pagesize?: number;
}) =>
api.get<StockStatementRow[]>(`${WEB}/products/getstockstatement`, {
api.list<StockStatementRow>(`${WEB}/products/getstockstatement`, {
tenantid: params.tenantid,
locationid: params.locationid,
subcategoryid: params.subcategoryid,

View File

@@ -62,7 +62,7 @@ export const tenantsApi = {
* endpoint rather than a tenant-scoped one.
*/
listAll: (query: TenantListQuery = {}) =>
api.get<TenantInfo[]>(`${WEB}/tenants/getalltenants`, {
api.list<TenantInfo>(`${WEB}/tenants/getalltenants`, {
pageno: query.pageno ?? 1,
pagesize: query.pagesize ?? 100,
status: query.status,
@@ -83,14 +83,14 @@ export const tenantsApi = {
* so this list is a queue to work from, not one to act on.
*/
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
api.get<TenantInfo[]>(`${WEB}/tenants/search`, { status, keyword }),
api.list<TenantInfo>(`${WEB}/tenants/search`, { status, keyword }),
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
locations: (tenantid: number) =>
api.get<TenantLocation[]>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
api.list<TenantLocation>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
search: (keyword: string) =>
api.get<TenantInfo[]>(`${WEB}/tenants/searchbykeyword`, { keyword }),
api.list<TenantInfo>(`${WEB}/tenants/searchbykeyword`, { keyword }),
/**
* Provisions the enterprise, its first outlet, and the primary Administrator
@@ -187,5 +187,5 @@ export const utilsApi = {
* form and never called this, which means a category added to the master is
* invisible to onboarding until someone edits the frontend.
*/
appCategories: () => api.get<AppCategory[]>(`${WEB}/utils/getappcategories`),
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
};

View File

@@ -25,6 +25,12 @@ export interface FiestaEnvelope<T> {
code: number;
message?: string;
details?: T;
/**
* The other payload key. Fiesta is not consistent about which one it uses:
* `products/getallproducts` and `products/create` answer with `data`, most
* everything else with `details`. The client reads `details ?? data`.
*/
data?: T;
/** Present on the tenant login endpoints. */
tenantform?: boolean;
}
@@ -469,8 +475,22 @@ export interface PosTerminalHealth {
[key: string]: string | undefined;
}
/** `GET /pos/health/location` returns the tills at one outlet, newest state first. */
export type PosLocationHealth = PosTerminalHealth[];
/**
* `GET /pos/health/location` — the tills at one outlet, newest state first.
*
* The envelope's `details` is an **object**, not an array: the controller wraps
* the terminal list in a count (`posController.go:298-307`). This was typed as a
* bare array until a real sign-in blanked the Console page — the fixture
* returned an array, so nothing caught it until the backend answered.
* `insightsApi.posHealth` unwraps it, so callers still receive the list.
*/
export interface PosLocationHealth {
location_id?: string | number;
total?: number;
/** Fiesta's own count of `status == "online"`. Recomputed in `posStatus.ts`. */
online?: number;
terminals?: PosTerminalHealth[];
}
/** `GET /pos/sales/summary` — verified against `models/pos.go` `PosSalesSummary`. */
export interface PosSalesSummary {

View File

@@ -1,18 +1,16 @@
import { createContext, use, useCallback, useMemo, useState, type ReactNode } from 'react';
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, persist, restore } from './session';
import { disableDemo, enableDemo } from '@/demo';
import { clear, login as loginRequest, restore } from './session';
import { AuthContext, useAuth } from './context';
interface AuthContextValue {
user: SessionUser | null;
signIn: (email: string, password: string) => Promise<SessionUser>;
/** Development only — see `src/demo`. Absent from a production build. */
signInAsDemo: (session: SessionUser) => void;
signOut: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
/**
* 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());
@@ -23,38 +21,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return session;
}, []);
/**
* Seeds a session without touching the API. Demo mode is switched on at the
* same moment, so the fixture backend and the fixture session can never be
* out of step — a demo user paired with live data would be the worst of both.
*/
const signInAsDemo = useCallback((session: SessionUser) => {
if (!import.meta.env.DEV) return;
enableDemo();
persist(session);
setUser(session);
}, []);
const signOut = useCallback(() => {
if (import.meta.env.DEV) disableDemo();
clear();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, signIn, signInAsDemo, signOut }),
[user, signIn, signInAsDemo, signOut],
() => ({ user, signIn, signOut }),
[user, signIn, signOut],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth(): AuthContextValue {
const context = use(AuthContext);
if (!context) throw new Error('useAuth must be used inside <AuthProvider>');
return context;
}
/**
* Route guard.
*

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

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

View File

@@ -77,6 +77,35 @@ export async function login(email: string, password: string): Promise<SessionUse
return session;
}
/** 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.
*
* `PUT /users/update` doubles as the password call. There is no dedicated
* endpoint and no reset flow — the controller says so in as many words
* (`userController.go:145`): "this endpoint also doubles as the
* password-setup/reset call (userid + password only, everything else left zero
* so GORM's `Updates` skips it)". Sending only those two fields is therefore
* load-bearing: a struct with any other field populated would write it.
*
* This is reachable only with the `userid` that `applogin` just handed back for
* an account it confirmed has an empty password. It is not a "change my
* password" call and must not be wired up as one — nothing here verifies the
* old password, because there is no old password.
*
* 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.put<unknown>(`${WEB}/users/update`, { userid, password });
}
/**
* The backend's own words, where they are usable, and ours where they are not.
*

View File

@@ -0,0 +1,107 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
/**
* 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 });
};
override render() {
const { error } = this.state;
if (!error) return this.props.children;
const area = this.props.area ?? 'this screen';
return (
<div
role="alert"
style={{
margin: '48px auto',
maxWidth: 620,
padding: '28px 32px',
borderRadius: 16,
border: '1px solid #F1D3D3',
background: '#FFFBFB',
fontFamily: 'inherit',
}}
>
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600, color: '#8A1F1F' }}>
Something on {area} failed to render
</h2>
<p style={{ margin: '0 0 16px', fontSize: 14, lineHeight: 1.6, color: '#5C4747' }}>
The rest of the console is fine — this is one screen, not the whole app. The message
below is what broke, and the full stack is in the browser console.
</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={this.reset}
style={{
padding: '9px 18px',
borderRadius: 999,
border: '1px solid #D9C0C0',
background: '#FFFFFF',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
Try again
</button>
</div>
);
}
}

View File

@@ -17,9 +17,19 @@ export interface PageHeaderProps {
* The page frame header, built to KROW's `AdminPage` spec.
*
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
* right-aligned and wrapping, and — the part that makes pages feel like one
* product — a hairline rule beneath the whole thing at half-opacity, with 16px
* of padding above it. Everything the page stacks below sits on a 24px rhythm.
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
* rhythm.
*
* 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.
*/
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
return (
@@ -31,8 +41,6 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 50%, transparent)',
paddingBottom: 16,
}}
>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
@@ -103,7 +111,7 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
</div>
) : null}
</header>
{tabs}
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, X } from 'lucide-react';
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';
@@ -87,29 +88,12 @@ export function AppShell({
const { user, signOut } = useAuth();
const { pathname } = useLocation();
const [query, setQuery] = useState('');
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
const [isNavOpen, setIsNavOpen] = useState(false);
const isMobile = useIsMobile();
const searchRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// ⌘K / Ctrl-K focuses search, the same global shortcut the reference uses.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
searchRef.current?.focus();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
const isSearchWide = isSearchFocused || query !== '';
// 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.
@@ -181,6 +165,7 @@ export function AppShell({
/>
</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
@@ -243,75 +228,21 @@ export function AppShell({
</nav>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{/* Search: 176px at rest, 256px once focused or filled. */}
<form
role="search"
className="show-from-md"
onSubmit={(event) => event.preventDefault()}
style={{
position: 'relative',
alignItems: 'center',
width: isSearchWide ? 256 : 176,
transition: 'width .2s cubic-bezier(.16,1,.3,1)',
}}
>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
/>
<input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder="Search"
aria-label="Search the console"
style={{
height: 32,
width: '100%',
borderRadius: 12,
border: `1px solid ${isSearchFocused ? 'var(--color-brand)' : 'var(--color-line)'}`,
background: isSearchFocused
? 'var(--color-surface)'
: 'var(--color-surface-subtle)',
paddingLeft: 32,
paddingRight: 40,
fontSize: 13,
color: 'var(--color-ink-1)',
outline: 'none',
transition: 'border-color .2s, background .2s',
boxShadow: isSearchFocused ? '0 0 0 2px rgba(102,37,130,.15)' : 'none',
}}
/>
<kbd
style={{
position: 'absolute',
right: 8,
borderRadius: 4,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
padding: '0 4px',
fontSize: 10,
fontWeight: 500,
color: 'var(--color-ink-4)',
pointerEvents: 'none',
}}
>
⌘K
</kbd>
</form>
{/* 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. */}
{headerActions}
<IconButton label="Notifications, 2 unread" hasUnread>
<Bell size={16} />
</IconButton>
{/* 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 */}
@@ -473,7 +404,14 @@ export function AppShell({
row from md where it becomes a side column. */}
<div className="admin-body app-gutter">
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
<Outlet />
{/* 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 ? (

View File

@@ -16,9 +16,9 @@ const CONTEXT: Record<
> = {
'/nearle/stores': {
page: 'Stores',
title: 'Good afternoon',
title: 'Nearle Buddy',
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
reading: 'Reading the tenant directory — branch counts, status and per-tenant performance.',
reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.',
prompts: [
'Which tenants have no branches?',
'Who onboarded most recently?',
@@ -30,14 +30,14 @@ const CONTEXT: Record<
page: 'Onboard tenant',
title: 'Provisioning a tenant',
greeting: 'Registering the enterprise also creates its primary Administrator account.',
reading: 'Reading the onboarding form — required fields, category and head-office address.',
reading: 'Would cover the onboarding form — required fields, category and head-office address.',
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
},
'/nearle/catalogue': {
page: 'Global catalogue',
title: 'Stocking a store',
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
reading: 'Reading the global catalogue and what this tenant has already imported.',
reading: 'Would cover the global catalogue and what this tenant has already imported.',
prompts: [
'Which products are already imported?',
'Catalogue or spreadsheet?',
@@ -52,7 +52,7 @@ const CONTEXT: Record<
page: 'Console',
title: 'Across your branches',
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
reading: 'Reading every branch — revenue by channel, stock health, till status and what is waiting on you.',
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?',
@@ -64,7 +64,7 @@ const CONTEXT: Record<
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: 'Reading orders, counter bills and delivery progress across your branches.',
reading: 'Would cover orders, counter bills and delivery progress across your branches.',
prompts: [
'Why is the cancel rate high?',
'Which orders are stuck?',
@@ -76,7 +76,7 @@ const CONTEXT: Record<
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: 'Reading your catalogue, per-branch stock levels and the stock requests waiting on you.',
reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.',
prompts: [
'What is waiting for approval?',
'Which products are unpublished?',
@@ -88,7 +88,7 @@ const CONTEXT: Record<
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: 'Reading your back-office directory and the till accounts at each branch.',
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?',
@@ -100,10 +100,10 @@ const CONTEXT: Record<
page: 'Reports',
title: 'Revenue, sales and stock',
greeting: 'Fast and slow movers are the two lists that change what you order next.',
reading: 'Reading revenue by channel and branch, product performance and stock movement.',
reading: 'Would cover revenue by channel and branch, product performance and stock movement.',
prompts: [
'Which products are slow moving?',
'Compare my three branches',
'Compare my branches',
'Online versus counter revenue',
'What is my inventory worth?',
],
@@ -114,7 +114,7 @@ const FALLBACK = {
page: 'Console',
title: 'Good afternoon',
greeting: 'Ask about anything on this page.',
reading: 'Reading this page.',
reading: 'Would cover this page.',
prompts: ['What needs attention?', 'Summarise this page'],
};
@@ -295,6 +295,13 @@ export function AssistantPanel({
))}
</div>
{/* Disabled, not merely inert.
This composer used to accept text and enable a brand-purple send
button the moment you typed — then swallow the submit. There is no
assistant endpoint anywhere in `src/api`. A disabled field says the
same thing honestly and costs nobody a message they thought they
sent. */}
<form
onSubmit={(event) => event.preventDefault()}
style={{
@@ -323,8 +330,9 @@ export function AssistantPanel({
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={`Ask Buddy about ${context.page.toLowerCase()}…`}
aria-label="Ask Nearle Buddy"
disabled
placeholder="Not connected yet"
aria-label="Ask Nearle Buddy — not connected yet"
style={{
display: 'block',
width: '100%',
@@ -343,18 +351,18 @@ export function AssistantPanel({
<button
type="submit"
aria-label="Send message"
disabled={draft.trim() === ''}
disabled
style={{
width: 28,
height: 28,
borderRadius: 999,
border: 0,
flex: 'none',
background: draft.trim() ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: draft.trim() ? '#fff' : 'var(--color-ink-4)',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-4)',
display: 'grid',
placeItems: 'center',
cursor: draft.trim() ? 'pointer' : 'default',
cursor: 'default',
transition: 'background .2s',
}}
>
@@ -371,7 +379,7 @@ export function AssistantPanel({
textAlign: 'center',
}}
>
Buddy reads this page&apos;s data. Check anything you act on.
Not connected yet — there is no assistant service behind this panel.
</p>
</div>
</aside>

View File

@@ -1,137 +0,0 @@
/**
* Demo sign-in — development only.
*
* Reached solely through a lazy import guarded by `import.meta.env.DEV`, so
* this file and `./accounts` are absent from a production build.
*/
import { ArrowRight } from 'lucide-react';
import { ROLE_LABEL, type ConsoleRole, type SessionUser } from '@/auth/roles';
import { DEMO_ACCOUNTS } from './accounts';
/**
* Three one-click sessions, one per role.
*
* Present only when `import.meta.env.DEV` is true, so a production build drops
* this component and the fixtures behind it. It is deliberately styled as a
* development tool rather than as part of the form — dashed border, a "DEV
* ONLY" tag — because a sign-in shortcut that looks like a real sign-in option
* is exactly the thing that ends up in a screenshot for a customer.
*/
export function DemoPanel({
onDemo,
}: {
onDemo: (role: ConsoleRole, session: SessionUser) => void;
}) {
const roles: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
return (
<div
style={{
border: '1px dashed var(--color-border-emphasized, #cbd2db)',
borderRadius: 14,
padding: 14,
background: 'color-mix(in oklab, var(--color-surface-sunken) 55%, transparent)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span
style={{
background: 'var(--color-ink-1)',
color: '#fff',
borderRadius: 999,
padding: '2px 7px',
fontSize: 9.5,
fontWeight: 700,
letterSpacing: '0.08em',
}}
>
DEV ONLY
</span>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-2)' }}>
Sign in without the backend
</span>
</div>
<p style={{ margin: '0 0 10px', fontSize: 11.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
Seeds a session and serves fixtures instead of calling Fiesta. Stripped from production
builds.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{roles.map((role) => {
const account = DEMO_ACCOUNTS[role];
if (!account) return null;
return (
<button
key={role}
type="button"
onClick={() => onDemo(role, account.session)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
textAlign: 'left',
padding: '9px 11px',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
cursor: 'pointer',
transition: 'border-color .2s, background .2s',
}}
>
<span
style={{
width: 26,
height: 26,
borderRadius: 8,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
fontSize: 11,
fontWeight: 700,
flex: 'none',
}}
>
{account.session.name
.split(' ')
.map((part) => part[0])
.slice(0, 2)
.join('')}
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span
style={{
display: 'block',
fontSize: 12.5,
fontWeight: 600,
color: 'var(--color-ink-1)',
}}
>
{ROLE_LABEL[role]}
<span style={{ fontWeight: 400, color: 'var(--color-ink-4)' }}>
{' '}
· {account.standsFor}
</span>
</span>
<span
style={{
display: 'block',
fontSize: 11,
lineHeight: 1.45,
color: 'var(--color-ink-3)',
}}
>
{account.blurb}
</span>
</span>
<ArrowRight size={14} style={{ color: 'var(--color-ink-4)', flex: 'none' }} />
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
/**
* The three demo accounts.
*
* Split from `src/demo/index.ts` so they can be reached only through the lazily
* loaded demo panel — a static import would put these session objects in the
* production bundle, and a build that ships fake accounts is a build that
* eventually ships one to a customer.
*
* DEV ONLY.
*/
import type { SessionUser } from '@/auth/roles';
export interface DemoAccount {
session: SessionUser;
/** What this role is for, in one line, shown on the button. */
blurb: string;
/** The real login this stands in for, so it is obvious what it maps to. */
standsFor: string;
}
/**
* One per role, matching how `resolveRole` actually decides:
* `issuperadmin` first, then roleid 1 or 3, then everything else.
*/
export const DEMO_ACCOUNTS: Record<string, DemoAccount> = {
'nearle-admin': {
session: {
userid: 9001,
role: 'nearle-admin',
name: 'Suriya Kumar',
email: 'suriya@superadmin.com',
roleid: 0,
tenantid: 0,
locationid: 0,
issuperadmin: true,
},
blurb: 'Onboard tenants and branches, browse the global catalogue, see every store.',
standsFor: 'issuperadmin = true',
},
'store-admin': {
session: {
userid: 9002,
role: 'store-admin',
name: 'Rmart Operations',
email: 'rmart@gmail.com',
roleid: 1,
tenantid: 1135,
locationid: 1,
issuperadmin: false,
},
blurb: 'One merchant group — its branches, catalogue, reports and tills.',
standsFor: 'roleid 1',
},
'store-manager': {
session: {
userid: 9003,
role: 'store-manager',
name: 'R Mart User',
email: 'rmartuser@gmail.com',
roleid: 5,
tenantid: 1135,
locationid: 1,
issuperadmin: false,
},
blurb: 'A single store — its orders, stock, customers and counter sales.',
standsFor: 'any other roleid',
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,300 +0,0 @@
/**
* Demo mode — three signed-in roles and a fixture backend, for working on the
* console without reaching Fiesta.
*
* WHY THIS EXISTS. The API is wired and correct, but it has never been
* exercised end to end from a development machine that cannot reach
* `fiesta.nearle.app`, and the Store Admin and Store Manager workspaces have no
* accounts to sign into yet. Rather than leave every screen showing an error,
* demo mode seeds a session and answers the same endpoints from fixtures.
*
* HOW IT IS GATED — two locks, and both must be open:
*
* 1. `import.meta.env.DEV` — a build-time constant. In a production build
* this is `false`, so every branch below is dead code that the bundler
* removes entirely. The fixtures never ship.
* 2. A `sessionStorage` flag the person sets by clicking a demo button. Even
* in development, a normal sign-in against the real API is unaffected
* unless demo mode was explicitly chosen.
*
* This is a development affordance, NOT an authentication bypass: it cannot be
* reached in a production build at all, and it grants nothing on the server —
* it only decides what this browser tab renders.
*/
const FLAG = 'nearle.demo.v1';
/** Compile-time gate. `false` in a production build, so everything below drops. */
export const DEMO_AVAILABLE = import.meta.env.DEV;
export function isDemoActive(): boolean {
if (!DEMO_AVAILABLE) return false;
try {
return sessionStorage.getItem(FLAG) === 'on';
} catch {
return false;
}
}
export function enableDemo(): void {
sessionStorage.setItem(FLAG, 'on');
}
export function disableDemo(): void {
sessionStorage.removeItem(FLAG);
}
/* ────────────────────────────────────────────────────────────────────────────
The fixture backend
──────────────────────────────────────────────────────────────────────────── */
/**
* Answers a request from fixtures, or returns `MISS` to let the real call go
* out.
*
* `MISS` is a sentinel rather than `undefined` because an endpoint legitimately
* answering `undefined` and an endpoint we have no fixture for are different
* things, and collapsing them would silently swallow the second.
*/
export const MISS = Symbol('demo-miss');
/**
* Answers a request from fixtures, or returns `MISS` to let the real call go
* out.
*
* `MISS` is a sentinel rather than `undefined` because an endpoint legitimately
* answering `undefined` and an endpoint we have no fixture for are different
* things, and collapsing them would silently swallow the second.
*
* The fixtures are behind a dynamic import INSIDE the `import.meta.env.DEV`
* guard, and that is load-bearing rather than stylistic: with a static import
* the bundler cannot prove the module is unreachable, so the whole fixture set
* ships to production. Verified by grepping the built assets for fixture
* strings — see the note in the README.
*/
export async function demoResolve(
path: string,
params: Record<string, unknown> | undefined,
): Promise<unknown | typeof MISS> {
if (!import.meta.env.DEV) return MISS;
const f = await import('./fixtures');
const tenantid = Number(params?.['tenantid'] ?? 0);
if (path.endsWith('/tenants/getalltenants')) return f.DEMO_TENANTS;
if (path.endsWith('/tenants/gettenantlocations')) {
return f.DEMO_LOCATIONS[tenantid] ?? [];
}
if (path.endsWith('/catalogue/getproducts')) {
const brand = String(params?.['brand'] ?? '');
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
return f.DEMO_CATALOGUE.filter(
(product) =>
(brand === '' || product.brand === brand) &&
(keyword === '' || product.product_name.toLowerCase().includes(keyword)),
);
}
if (path.endsWith('/catalogue/getbrands')) return f.DEMO_BRANDS;
if (path.endsWith('/products/getimportedcatalogueproducts')) {
return f.DEMO_IMPORTED[tenantid] ?? [];
}
if (path.endsWith('/products/getproductcategories')) return f.DEMO_CATEGORIES;
if (path.endsWith('/products/getlocationproducts')) return f.DEMO_STORE_PRODUCTS;
if (path.endsWith('/products/gettenantcategories')) return f.DEMO_TENANT_CATEGORIES;
// Returns null for anything not listed — the common real case, where a
// re-scrape has retired the source row the tenant's snapshot came from.
if (path.endsWith('/catalogue/getproduct')) {
const sku = String(params?.['sku'] ?? '');
return f.DEMO_CATALOGUE_DETAIL[sku] ?? null;
}
if (path.endsWith('/orders/getlocationsummary')) {
return f.DEMO_LOCATION_SUMMARY[tenantid] ?? [];
}
if (path.endsWith('/orders/getordersummary')) {
return f.DEMO_ORDER_SUMMARY[tenantid] ?? {};
}
/* ── Store Admin ──────────────────────────────────────────────────────── */
if (path.endsWith('/orders/tenant/getorders') || path.endsWith('/orders/getorders')) {
const locationid = Number(params?.['locationid'] ?? 0);
const status = String(params?.['status'] ?? '');
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
return f.DEMO_ORDERS.filter(
(order) =>
(!locationid || order.locationid === locationid) &&
(status === '' || order.orderstatus === status) &&
(keyword === '' ||
`${order.orderid} ${order.deliverycustomer}`.toLowerCase().includes(keyword)),
);
}
if (path.endsWith('/deliveries/getdeliveries')) {
const locationid = Number(params?.['locationid'] ?? 0);
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
return f.DEMO_DELIVERIES.filter(
(job) =>
(!locationid || job.locationid === locationid) &&
(keyword === '' ||
`${job.orderid} ${job.deliverycustomer} ${job.ridername}`
.toLowerCase()
.includes(keyword)),
);
}
if (path.includes('/pos/sales') && !path.includes('summary') && !path.includes('detail')) {
const locationid = Number(params?.['locationid'] ?? 0);
const bills = f.DEMO_POS_BILLS[locationid] ?? [];
return { total: bills.length, pageno: 0, pagesize: 100, bills };
}
if (path.includes('/health/location')) {
const locationid = Number(params?.['location_id'] ?? 0);
return f.DEMO_POS_HEALTH[locationid] ?? [];
}
if (path.includes('/sales/summary')) {
const locationid = Number(params?.['locationid'] ?? 0);
return f.DEMO_POS_SUMMARY[locationid] ?? {};
}
if (path.endsWith('/tenants/getstaffs')) return f.DEMO_STAFF;
if (path.endsWith('/posroles')) return f.DEMO_POS_ROLES;
if (path.endsWith('/getposusers')) {
const locationid = Number(params?.['locationid'] ?? 0);
return f.DEMO_POS_USERS[locationid] ?? [];
}
if (path.endsWith('/getstaffshifts')) {
const locationid = Number(params?.['locationid'] ?? 0);
return f.DEMO_SHIFTS[locationid] ?? [];
}
// A created till account answers with its PIN once — the same shape the real
// endpoint uses, so the "shown once" screen is exercised in demo too.
if (path.includes('/createposuser')) {
return { user_id: 7099, full_name: 'New till user', role: 'cashier', status: 'Active', pin: '4821' };
}
if (path.includes('/updateposuser')) return { user_id: 7099, status: 'Active' };
if (path.includes('/users/create')) return { userid: 9099, status: 'Active' };
if (path.includes('/users/update')) return { userid: 9099, status: 'Active' };
if (path.endsWith('/products/getstockrequests')) {
const locationid = Number(params?.['locationid'] ?? 0);
const status = String(params?.['status'] ?? '');
return f.DEMO_STOCK_REQUESTS.filter(
(request) =>
(!locationid || request.locationid === locationid) &&
(status === '' || request.status === status),
);
}
if (path.endsWith('/products/getstockstatement')) {
const locationid = Number(params?.['locationid'] ?? 0);
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
const rows = f.DEMO_STOCK_STATEMENT[locationid] ?? [];
return keyword === ''
? rows
: rows.filter((row) => (row.productname ?? '').toLowerCase().includes(keyword));
}
if (path.includes('/updatestockrequest')) return { updated: true };
if (path.endsWith('/tenants/search')) {
// `status=pending` is not a status — the handler branches on the word and
// queries `approved = 0`. Two rows, so the queue is not empty in demo.
const status = String(params?.['status'] ?? '');
if (status === 'pending') return f.DEMO_PENDING_TENANTS;
return f.DEMO_TENANTS.filter((t) => (t.status ?? '').toLowerCase() === status.toLowerCase());
}
if (path.endsWith('/utils/getappcategories')) return f.DEMO_APP_CATEGORIES;
if (path.endsWith('/products/getsaletemplate')) {
const locationid = Number(params?.['locationid'] ?? 0);
const locations = f.DEMO_LOCATIONS[tenantid] ?? [];
const scoped = locationid ? locations.filter((l) => l.locationid === locationid) : locations;
const products = scoped.flatMap((location) =>
f.DEMO_STORE_PRODUCTS.map((product) => ({
tenantid,
locationid: location.locationid,
locationname: location.locationname,
productid: product.productid,
productname: product.productname ?? '',
productunit: product.productunit ?? '',
unitvalue: product.unitvalue ?? '',
categoryname: product.categoryname ?? '',
currentstock: product.productstock ?? 0,
price: product.price ?? product.retailprice ?? 0,
taxpercent: product.taxpercent ?? 0,
})),
);
return {
tenantid,
locationid,
locations: scoped.map((location) => ({
locationid: location.locationid,
locationname: location.locationname,
productcount: f.DEMO_STORE_PRODUCTS.length,
})),
products,
};
}
if (path.includes('/uploadofflinesales')) {
// Echoes the shape the real endpoint returns, including a duplicate — the
// case the result screen exists to explain.
return {
imported: 2,
duplicate: 1,
failed: 0,
totalamount: 1240,
results: [
{ locationid: 1, locationname: 'R mart — RS Puram', billno: 'A-11', status: 'imported', orderheaderid: 5501, itemcount: 3, amount: 740 },
{ locationid: 1, locationname: 'R mart — RS Puram', billno: 'A-12', status: 'imported', orderheaderid: 5502, itemcount: 2, amount: 500 },
{ locationid: 1, locationname: 'R mart — RS Puram', billno: 'A-09', status: 'duplicate', itemcount: 1, amount: 0, message: 'already imported for this store' },
],
};
}
if (path.includes('/createstockrequest')) {
return { requestid: 9901, status: 'Pending' };
}
if (path.endsWith('/customers/gettenantcustomers')) {
const locationid = Number(params?.['locationid'] ?? 0);
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
const rows = (f.DEMO_CUSTOMERS[locationid] ?? []).filter((row) =>
keyword === ''
? true
: `${row.firstname ?? ''} ${row.lastname ?? ''} ${row.contactno ?? ''}`
.toLowerCase()
.includes(keyword),
);
return rows;
}
// Writes acknowledge without persisting: the point of demo mode is to walk
// the screens, and a form that silently fails teaches the wrong thing.
if (path.includes('/createtenantlocation')) {
return { tenantid: 1140, locationid: 20, tenantname: 'New tenant', status: 'Active' };
}
if (path.includes('/createlocation')) {
return { locationid: 21, tenantid, locationname: 'New branch', status: 'Active' };
}
if (path.includes('/importcatalogueproduct')) return { imported: true };
return MISS;
}

View File

@@ -1,19 +1,23 @@
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react';
import { useState, type FormEvent, type ReactNode } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react';
import {
AlertCircle,
ArrowRight,
Eye,
EyeOff,
Loader2,
Lock,
Mail,
ShieldCheck,
Sparkles,
} from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { HOME_ROUTE } from '@/auth/roles';
import { PasswordSetupRequiredError } from '@/auth/session';
import type { ConsoleRole, SessionUser } from '@/auth/roles';
/**
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
* production build this expression is `null`, the import is dead code, and
* neither the panel nor the accounts behind it are emitted at all.
*/
const DemoPanel = import.meta.env.DEV
? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel })))
: null;
import {
MIN_PASSWORD_LENGTH,
PasswordSetupRequiredError,
setInitialPassword,
} from '@/auth/session';
/**
* Sign-in — KROW's full-bleed auth archetype.
@@ -29,16 +33,27 @@ const DemoPanel = import.meta.env.DEV
* lands where their account says they belong.
*/
export function LoginPage() {
const { user, signIn, signInAsDemo } = useAuth();
const { user, signIn } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isRemembered, setIsRemembered] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
/**
* The userid `applogin` returned for an account with no password, or null.
*
* Non-null flips this page into its second state. It is deliberately not a
* route: the userid only exists because a sign-in attempt 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('');
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
async function handleSubmit(event: FormEvent) {
@@ -49,19 +64,52 @@ export function LoginPage() {
const session = await signIn(email, password);
navigate(HOME_ROUTE[session.role], { replace: true });
} catch (cause) {
setError(
cause instanceof PasswordSetupRequiredError
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
: cause instanceof Error
? cause.message
: 'Sign-in failed',
);
// Not a failure — the first step. The account exists and has no password,
// and the backend handed back the userid to set one against.
if (cause instanceof PasswordSetupRequiredError) {
setSetupUserid(cause.userid);
setNewPassword('');
setConfirmPassword('');
} else {
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
}
} finally {
setIsBusy(false);
}
}
/**
* 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 = email.trim() !== '' && password !== '' && !isBusy;
const canSetup =
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
return (
<div
@@ -81,24 +129,38 @@ export function LoginPage() {
}}
>
<BrandPanel />
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
isRemembered={isRemembered}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
onSubmit={handleSubmit}
onDemo={(role, session) => {
signInAsDemo(session);
navigate(HOME_ROUTE[role], { replace: true });
}}
/>
{setupUserid === null ? (
<FormPanel
email={email}
password={password}
isPasswordVisible={isPasswordVisible}
error={error}
isBusy={isBusy}
canSubmit={canSubmit}
onEmail={setEmail}
onPassword={setPassword}
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
onSubmit={handleSubmit}
/>
) : (
<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={() => {
setSetupUserid(null);
setError(null);
}}
/>
)}
</div>
</div>
);
@@ -238,32 +300,26 @@ interface FormPanelProps {
email: string;
password: string;
isPasswordVisible: boolean;
isRemembered: boolean;
error: string | null;
isBusy: boolean;
canSubmit: boolean;
onEmail: (value: string) => void;
onPassword: (value: string) => void;
onToggleVisible: () => void;
onToggleRemember: () => void;
onSubmit: (event: FormEvent) => void;
onDemo: (role: ConsoleRole, session: SessionUser) => void;
}
function FormPanel({
email,
password,
isPasswordVisible,
isRemembered,
error,
isBusy,
canSubmit,
onEmail,
onPassword,
onToggleVisible,
onToggleRemember,
onSubmit,
onDemo,
}: FormPanelProps) {
return (
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
@@ -311,21 +367,7 @@ function FormPanel({
type="button"
onClick={onToggleVisible}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
style={{
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',
}}
style={eyeButtonStyle}
>
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
@@ -343,104 +385,23 @@ function FormPanel({
/>
</Field>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--color-ink-2)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isRemembered}
onChange={onToggleRemember}
style={{ width: 15, height: 15, accentColor: 'var(--color-brand)', cursor: 'pointer' }}
/>
Keep me signed in
</label>
{/* 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. */}
<button
type="button"
style={{
border: 0,
background: 'transparent',
padding: 0,
fontSize: 13,
fontWeight: 600,
color: 'var(--color-brand)',
cursor: 'pointer',
}}
>
Forgot password?
</button>
</div>
<ErrorNote message={error} />
{error ? (
<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 }} />
{error}
</div>
) : null}
<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' }} />
Signing in…
</>
) : (
<>
Continue
<ArrowRight size={16} />
</>
)}
</button>
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Signing in…"
label="Continue"
/>
</form>
<p
@@ -456,16 +417,289 @@ function FormPanel({
opens — you do not pick one.
</p>
{DemoPanel ? (
<Suspense fallback={null}>
<DemoPanel onDemo={onDemo} />
</Suspense>
) : null}
</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>
<ErrorNote message={error} />
<SubmitButton
canSubmit={canSubmit}
isBusy={isBusy}
busyLabel="Setting it…"
label="Set password and sign in"
/>
</form>
<button
type="button"
onClick={onBack}
style={{
alignSelf: 'center',
border: 0,
background: 'transparent',
padding: 0,
fontSize: 12.5,
fontFamily: 'inherit',
color: 'var(--color-ink-3)',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 3,
}}
>
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>
);
}
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 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
──────────────────────────────────────────────────────────────────────────── */

View File

@@ -0,0 +1,420 @@
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 { TextInput } from '@astryxdesign/core/TextInput';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Funnel, PackageSearch, Search, SearchX } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { 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;
/** The tenant's own categories, for the one field the catalogue cannot supply. */
categoryOptions: { value: string; label: string }[];
/** Wording on the card and drawer buttons. */
actionLabel: string;
/**
* 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;
/**
* Lift the search row onto the page's tab row.
*
* Only true where a tab row actually exists — the Store Admin's Inventory.
* On a page without one the offset drags the row up over the page header's
* own actions and swallows their clicks, which is exactly what it did to the
* platform catalogue's mode toggle.
*/
alignWithTabs?: boolean;
/** Why importing is unavailable, if it is. */
blockedReason?: string;
}
/**
* 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,
categoryOptions,
actionLabel,
onImport,
scope,
blockedReason,
isReadOnly,
alignWithTabs,
}: CatalogueBrowserProps) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [importInto, setImportInto] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
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(() => {
const set = new Set((imported.data ?? []).map(catalogueKey));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
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 }),
]);
},
});
async function importDirect(product: CatalogueProduct) {
if (!tenantid || !locationid) return;
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised — the backend leaves this
// optional for exactly that reason. An unclassified product is visibly
// unfinished; a wrongly classified one looks done and is only found by
// somebody browsing the wrong aisle.
categoryid: Number(importInto) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
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);
/**
* "Import into" lives in the drawer, at the moment of the decision.
*
* It used to sit in the filter row as well, which put a question about where
* ONE product should be filed next to three controls that change what the
* whole grid shows — and it read as a fourth filter. It is only offered when
* this component performs the import itself: when the caller takes over
* (`onImport`), its own form collects the category along with the price, and
* asking twice on one screen gets two answers that can disagree.
*/
const showCategoryPicker = !isReadOnly && !onImport && categoryOptions.length > 0;
function clearFilters() {
setBrand('');
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}
{/* Pulled up onto the tab row's line and right-aligned, so search sits
level with the page selector rather than on a line of its own — the
tab row is mostly empty to the right and was reserving a whole row
below it for four controls.
The pull-up is a CSS offset rather than the controls being rendered
inside the tab row, because the tab row belongs to the page
(`InventoryPage`) and these controls belong to the browser's own
state. Rendering them there would mean lifting `keyword` and the rail
toggle into two different pages to keep one row tidy. It reverts to
its own line below 900px, where the tabs need the width.
It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */}
<HStack
gap={1}
align="center"
wrap="wrap"
justify="end"
{...(alignWithTabs ? { className: 'catalogue-controls' } : {})}
>
{activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))}
{activeFilters.length > 1 ? (
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
) : null}
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
width={220}
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
startIcon={<Search size={14} />}
hasClear
/>
</HStack>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
{isFiltersOpen ? (
<CatalogueSidebar
brands={brands.data ?? []}
isLoading={brands.isLoading}
brand={brand}
category={category}
categories={(categories.data ?? []).filter(Boolean)}
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} />
),
}
: {})}
/>
) : (
<>
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
return (
<CatalogueCard
key={key}
product={product}
isImported={importedKeys.has(key)}
isBusy={busy === key}
isDisabled={!canImport}
actionLabel={actionLabel}
onOpen={() => setOpen(product)}
{...(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}
{...(showCategoryPicker
? { categoryOptions, categoryid: importInto, onCategoryChange: setImportInto }
: {})}
{...(canImport && !isReadOnly
? {
onImport: () => {
const product = open;
if (onImport) setOpen(null);
run(product);
},
}
: {})}
{...(blockedReason ? { blockedReason } : {})}
onClose={() => setOpen(null)}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,226 @@
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,
}: {
product: CatalogueProduct;
isImported: boolean;
isBusy?: boolean;
isDisabled?: boolean;
actionLabel: string;
onOpen: () => void;
onImport?: () => 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">
{image ? (
<img
// Keyed by the URL so a fall-through to the next photo actually
// remounts the element — React would otherwise keep the failed
// image node and never re-attempt the load.
key={image}
src={image}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => markFailed(shownAt)}
/>
) : (
<span className="pcard-noimage" title={photos > 0 ? `${photos} photos, none loaded` : 'No photo'}>
<ImageOff size={30} />
</span>
)}
<button
type="button"
className="pcard-open"
onClick={onOpen}
aria-label={`View ${product.product_name}`}
/>
{/* No brand flag. The reference puts a discount there and we have no
discount to show; a brand chip in its place restated what the rail
on the left already says, what the SKU under the name repeats, and
what the packaging itself carries in larger type than we could. */}
{isImported ? <span className="pcard-owned">In your list</span> : null}
{/* The action rail. One icon, because one is all we have a use for —
the reference's wishlist and compare have nothing behind them. */}
<span className="pcard-rail">
<span className="pcard-railbtn" aria-hidden="true">
<Eye size={14} />
</span>
</span>
{/* The photo switcher. Only when there is more than one to switch to —
an arrow that does nothing is worse than no arrow, and most of a
real catalogue's rows carry several shots that the browse card
otherwise never shows. */}
{hasMany ? (
<>
<button
type="button"
className="pcard-arrow is-left"
aria-label="Previous photo"
onClick={(event) => step(event, -1)}
>
<ChevronLeft size={15} />
</button>
<button
type="button"
className="pcard-arrow is-right"
aria-label="Next photo"
onClick={(event) => step(event, 1)}
>
<ChevronRight size={15} />
</button>
<span className="pcard-dots">
{images.slice(0, 8).map((src, position) => (
<button
type="button"
// Position, not the URL: a catalogue row can list the same
// photo twice, and React drops a duplicate key — which reads
// as a dot row shorter than the count it stands for.
key={`${position}-${src}`}
className="pcard-dot"
data-active={position === shownAt || undefined}
aria-label={`Photo ${position + 1}`}
onClick={(event) => {
event.stopPropagation();
setIndex(position);
}}
/>
))}
</span>
</>
) : null}
</div>
<div className="pcard-body">
{/* The reference puts a star rating here. There is no review data
anywhere in Fiesta, so the line carries what this catalogue does
know about the pack — its code and its size. */}
<p className="pcard-meta">
<span className="pcard-sku">{product.product_sku ?? `id ${product.id}`}</span>
{product.size ? <span className="pcard-size">{product.size}</span> : null}
{photos > 1 ? <span className="pcard-photos">{photos} photos</span> : null}
</p>
<button type="button" className="pcard-name" onClick={onOpen} title={product.product_name}>
{product.product_name}
</button>
{/* Price and action on ONE row, as in the reference. A full-width button
under every card made the action the loudest thing in a grid of
twenty-four; beside the price it is available without insisting. */}
<div className="pcard-foot">
{/* A RANGE — what other outlets list it at, not a price this store is
bound to. The store sets its own after the import. */}
<span className="pcard-price">{product.price_range || '—'}</span>
{onImport ? (
isImported ? (
<span className="pcard-action is-added">
<Check size={12} /> Added
</span>
) : (
/* "Add", not the full instruction.
The reference says "Add to cart" — three short words beside a
price. "Add to my products" is eighteen characters and wrapped
the price onto two lines at this card width. The full wording
stays as the accessible name, and the drawer says it in full at
the point where somebody is deciding rather than scanning. */
<button
type="button"
className="pcard-action"
title={actionLabel}
aria-label={actionLabel}
disabled={isDisabled || isBusy}
onClick={onImport}
>
<Plus size={12} /> {isBusy ? 'Adding…' : 'Add'}
</button>
)
) : null}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,337 @@
import { useEffect, useState } from 'react';
import { Banner } from '@astryxdesign/core/Banner';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Divider } from '@astryxdesign/core/Divider';
import { HStack } from '@astryxdesign/core/HStack';
import { Lightbox } from '@astryxdesign/core/Lightbox';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { Token } from '@astryxdesign/core/Token';
import { VStack } from '@astryxdesign/core/VStack';
import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react';
import type { CatalogueProduct } from '@/api/types';
import { Drawer } from '@/features/store-admin/Drawer';
/**
* 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;
/** The tenant's own categories. Omit to hide the picker. */
categoryOptions?: { value: string; label: string }[];
categoryid?: string;
onCategoryChange?: (value: string) => void;
/** Absent when the caller has nowhere to import to yet. */
onImport?: () => void;
/** 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',
categoryOptions,
categoryid = '',
onCategoryChange,
onImport,
blockedReason,
onClose,
}: CatalogueDetailDrawerProps) {
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}`}
titleAside={isImported ? <Token label="In your list" size="sm" color="green" /> : undefined}
width={540}
onClose={onClose}
>
{/* The photograph, at the size a label can be read at. Click to zoom —
at card size the ingredients and the net weight are not legible. */}
<Card padding={0} elevation="none">
<button
type="button"
aria-label="Open photo full size"
onClick={() => setIsZoomed(true)}
disabled={!hero || failed}
style={{
display: 'block',
width: '100%',
height: 280,
padding: 0,
border: '1px solid var(--color-line)',
borderRadius: 16,
background: 'var(--color-surface)',
cursor: hero && !failed ? 'zoom-in' : 'default',
overflow: 'hidden',
}}
>
{hero && !failed ? (
<img
src={hero}
alt={product.product_name}
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
mixBlendMode: 'multiply',
}}
/>
) : (
<ImageOff size={40} style={{ color: 'var(--color-line)' }} />
)}
</button>
</Card>
{images.length > 1 ? (
<VStack gap={0.5}>
<HStack gap={0.5} align="center">
<span style={{ color: 'var(--color-ink-4)', display: 'flex' }}>
<Info size={12} />
</span>
<Text type="body" size="xsm" color="secondary">
{images.length} photos — only the first is imported
</Text>
</HStack>
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
{images.map((src, index) => (
<button
type="button"
// Index, not the URL: a catalogue row can list the same photo
// twice, and a duplicate key makes React drop one thumbnail and
// mis-track the rest as you page through them.
key={`${index}-${src}`}
aria-label={`Show photo ${index + 1}`}
onClick={() => (index === heroAt ? setIsZoomed(true) : setHeroAt(index))}
style={{
width: 58,
height: 58,
flex: 'none',
padding: 2,
borderRadius: 10,
background: 'var(--color-surface)',
border:
index === heroAt
? '2px solid var(--color-brand)'
: '1px solid var(--color-line)',
opacity: index === heroAt ? 1 : 0.6,
cursor: 'pointer',
}}
>
<img
src={src}
alt=""
referrerPolicy="no-referrer"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</button>
))}
</div>
</VStack>
) : null}
{/* A RANGE, not a price. What the shop charges is set after the import,
and conflating the two is how a catalogue figure ends up on a shelf. */}
<Card padding={0} elevation="low">
<HStack justify="between" align="center" gap={2} padding={2}>
<VStack gap={0}>
<Text type="label" size="xsm" color="secondary">
MARKET PRICE RANGE
</Text>
<Text type="large" weight="semibold" hasTabularNumbers>
{product.price_range ?? '—'}
</Text>
</VStack>
{product.size ? <Token label={product.size} size="md" /> : null}
</HStack>
</Card>
{product.description ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
DESCRIPTION
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{product.description}
</Text>
</VStack>
) : null}
{product.highlights?.length || product.nutrients?.length ? (
<div className="form-grid">
{product.highlights?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
HIGHLIGHTS
</Text>
<VStack gap={0}>
{product.highlights.map((line, index) => (
<Text
key={`${index}-${line}`}
type="body"
size="xsm"
color="secondary"
style={{ lineHeight: 1.6 }}
>
• {line}
</Text>
))}
</VStack>
</VStack>
) : null}
{product.nutrients?.length ? (
<VStack gap={0.5}>
<Text type="label" size="xsm" color="secondary">
NUTRITION
</Text>
<VStack gap={0}>
{product.nutrients.map((line, index) => (
<Text
key={`${index}-${line}`}
type="body"
size="xsm"
color="secondary"
style={{ lineHeight: 1.6 }}
>
• {line}
</Text>
))}
</VStack>
</VStack>
) : null}
</div>
) : null}
{facts.length > 0 ? (
<>
<Divider />
<VStack gap={1}>
{facts.map((fact) => (
<HStack key={fact.label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{fact.label}
</Text>
<Text
type="body"
size="xsm"
style={{
textAlign: 'right',
...(fact.isMono ? { fontFamily: 'var(--font-mono)' } : {}),
}}
>
{fact.value}
</Text>
</HStack>
))}
</VStack>
</>
) : null}
{/* The action, last, because everything above is what the decision is
made on. */}
{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 ? (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
{categoryOptions ? (
<Selector
label="Import into"
size="sm"
options={categoryOptions}
value={categoryid}
onChange={(value) => onCategoryChange?.(value)}
placeholder="Leave uncategorised"
description="Your own category, not the catalogue's. Uncategorised beats wrongly categorised."
/>
) : null}
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Adds this product with no price. It reaches no shop and cannot be sold until you
price and publish it.
</Text>
<Button
label={isBusy ? 'Adding…' : actionLabel}
variant="primary"
icon={<DownloadCloud size={14} />}
width="full"
isDisabled={Boolean(isBusy)}
onClick={onImport}
/>
</VStack>
</Card>
) : null}
{/* Gallery mode, driven by the same index the thumbnails set — so
opening the third photo and paging on from it leaves the drawer
showing the third photo when it closes. */}
{images.length > 0 ? (
<Lightbox
isOpen={isZoomed}
onOpenChange={setIsZoomed}
media={images.map((src, index) => ({
src,
alt: `${product.product_name} — photo ${index + 1}`,
}))}
index={Math.min(heroAt, images.length - 1)}
onIndexChange={setHeroAt}
hasZoom
/>
) : null}
</Drawer>
);
}

View File

@@ -0,0 +1,137 @@
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 = 8;
/**
* 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,
onBrand,
onCategory,
}: {
brands: CatalogueBrand[];
isLoading: boolean;
brand: string;
category: string;
categories: string[];
isLoadingCategories: boolean;
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 ? '' : 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((name) => (
<button
key={name}
type="button"
className="rail-category"
data-active={category === name}
onClick={() => onCategory(category === name ? '' : name)}
>
{name}
</button>
))}
</>
)}
</VStack>
) : null}
</div>
);
})}
{hidden > 0 || isExpanded ? (
<button type="button" className="rail-more" onClick={() => setIsExpanded((on) => !on)}>
{isExpanded ? 'See less' : `See ${hidden} more`}
<ChevronDown size={13} style={{ transform: isExpanded ? 'rotate(180deg)' : undefined }} />
</button>
) : null}
</VStack>
</div>
</aside>
);
}

View File

@@ -1,256 +0,0 @@
import { useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { HStack } from '@astryxdesign/core/HStack';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertCircle } from 'lucide-react';
import { errorMessage } from '@/api/client';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { useProductCategories } from '@/queries/hooks';
export interface ImportDrawerProps {
product: CatalogueProduct;
tenantid: number;
locationid: number;
isSaving: boolean;
error: unknown;
onClose: () => void;
onSubmit: (row: ImportCatalogueProductRequest) => void;
}
/**
* The five things the catalogue cannot supply.
*
* The global catalogue carries a price RANGE and a free-text category, and
* neither maps onto a tenant's own pricing or category tree. So category,
* subcategory, retail price, cost and tax are collected here before Import can
* be enabled — an import with them missing produces a product a store cannot
* legally sell.
*/
export function ImportDrawer({
product,
tenantid,
locationid,
isSaving,
error,
onClose,
onSubmit,
}: ImportDrawerProps) {
const { data: categories } = useProductCategories(tenantid);
const [categoryid, setCategoryid] = useState('');
const [subcategoryid, setSubcategoryid] = useState('');
/**
* Seeded from the bottom of the catalogue's price range.
*
* A guess, and a better starting point than an empty box — the operator
* changes it or accepts it, but does not have to go and look the product up.
* The range is a display string ("₹33-37"), so the number is parsed out of
* it rather than read from a field.
*/
const [retailprice, setRetailprice] = useState<number | null>(() => priceFloor(product.price_range));
const [productcost, setProductcost] = useState<number | null>(null);
const [taxpercent, setTaxpercent] = useState<number | null>(0);
const [quantity, setQuantity] = useState<number | null>(0);
const categoryOptions = (categories ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
}));
const isComplete =
categoryid !== '' &&
typeof retailprice === 'number' &&
retailprice > 0 &&
typeof productcost === 'number' &&
typeof taxpercent === 'number';
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
className="dialog-panel"
padding={0}
>
<DialogHeader title="Add to store" subtitle="Set what this store will sell it for" onOpenChange={(open) => (open ? undefined : onClose())} hasDivider />
<VStack gap={3} padding={3}>
<HStack gap={2} align="start">
<div
style={{
width: 72,
height: 72,
borderRadius: 12,
background: 'var(--color-slate-50)',
flex: 'none',
overflow: 'hidden',
}}
>
{product.images?.[0] ? (
<img
src={product.images[0]}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : null}
</div>
<VStack gap={0.5}>
<Text type="large" weight="semibold">
{product.product_name}
</Text>
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
{product.brand} · id {product.id} · {product.size ?? '—'}
</Text>
<Text type="body" size="sm" color="secondary">
Catalogue range {product.price_range ?? '—'} — a guide, not a price. What you set below
is what the store sells at.
</Text>
</VStack>
</HStack>
<CatalogueFacts product={product} />
<div className="form-grid">
<Selector
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
options={categoryOptions}
value={categoryid}
onChange={setCategoryid}
placeholder="Select category…"
description="The tenant's own category, not the catalogue's."
hasSearch
/>
<Selector
label="Subcategory"
options={[{ value: '', label: 'None' }]}
value={subcategoryid}
onChange={setSubcategoryid}
placeholder="Select subcategory…"
isDisabled={categoryid === ''}
/>
<NumberInput label={<span>Retail price (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={retailprice} onChange={setRetailprice} min={0} />
<NumberInput label={<span>Cost (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={productcost} onChange={setProductcost} min={0} />
<NumberInput label="Tax (%)" value={taxpercent} onChange={setTaxpercent} min={0} max={100} />
<NumberInput
label="Opening stock"
value={quantity}
onChange={setQuantity}
min={0}
description="Re-importing adds to this rather than replacing it."
/>
</div>
{error ? (
<HStack
align="center"
gap={1}
padding={2}
style={{
background: 'var(--color-error-muted, #fceeee)',
borderRadius: 12,
color: 'var(--color-error, #d64545)',
}}
>
<AlertCircle size={16} />
<Text type="body" size="sm" style={{ color: 'inherit' }}>
{errorMessage(error)}
</Text>
</HStack>
) : null}
<HStack justify="end" gap={1.5}>
<Button label="Cancel" variant="secondary" onClick={onClose} />
<Button
label={isSaving ? 'Adding…' : 'Add to store'}
variant="primary"
isLoading={isSaving}
isDisabled={!isComplete}
onClick={() =>
onSubmit({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
categoryid: Number(categoryid),
subcategoryid: subcategoryid === '' ? 0 : Number(subcategoryid),
quantity: quantity ?? 0,
stocktype: 'in',
status: 'available',
retailprice: retailprice ?? 0,
productcost: productcost ?? 0,
taxpercent: taxpercent ?? 0,
})
}
/>
</HStack>
</VStack>
</Dialog>
);
}
/**
* The lower bound of a catalogue price range.
*
* "₹33-37" → 33. It is a display string with no fixed shape, so the first
* number in it is taken and anything unparseable gives null rather than a
* fabricated price.
*/
function priceFloor(range: string | undefined): number | null {
if (!range) return null;
const match = range.replace(/,/g, '').match(/\d+(\.\d+)?/);
if (!match) return null;
const value = Number(match[0]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* What the catalogue knows and the import does not carry.
*
* The import copies seven of nineteen fields. These are among the twelve it
* leaves behind, and they are the ones that decide whether this is the right
* product: the licence, who sells it, and what is in it. Shown here because
* after the import they are only reachable by going back to the catalogue.
*/
function CatalogueFacts({ product }: { product: CatalogueProduct }) {
const facts: [string, string][] = [];
if (product.category) facts.push(['Catalogue category', product.category]);
if (product.product_sku) facts.push(['SKU', product.product_sku]);
if (product.fssai_license) facts.push(['FSSAI licence', product.fssai_license]);
if (product.providers?.length) facts.push(['Sold on', product.providers.join(', ')]);
if ((product.images?.length ?? 0) > 1) {
facts.push(['Photos', `${product.images?.length} — only the first is imported`]);
}
if (facts.length === 0 && !product.highlights?.length) return null;
return (
<VStack gap={1}>
{facts.length > 0 ? (
<VStack gap={0.5}>
{facts.map(([label, value]) => (
<HStack key={label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{label}
</Text>
<Text type="body" size="xsm" style={{ textAlign: 'right' }}>
{value}
</Text>
</HStack>
))}
</VStack>
) : null}
{product.highlights?.length ? (
<VStack gap={0}>
{product.highlights.slice(0, 4).map((line) => (
<Text key={line} type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
• {line}
</Text>
))}
</VStack>
) : null}
</VStack>
);
}

View File

@@ -1,294 +1,171 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Check, PackagePlus, Upload } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { DataState } from '@/components/DataState';
import { PackageSearch, Upload } from 'lucide-react';
import type { TenantInfo } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import {
useCatalogueBrands,
useCatalogueProducts,
useImportedRefs,
useTenants,
} from '@/queries/hooks';
import { queryKeys } from '@/queries/keys';
import { ImportDrawer } from '../import/ImportDrawer';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useTenantLocations, useTenants } from '@/queries/hooks';
import { SheetImportPanel } from '../import/SheetImportPanel';
import type { TenantInfo } from '@/api/types';
/**
* The global catalogue browser, and the place both import paths start.
* The platform operator's catalogue.
*
* The list loads with NO brand filter — brand, category and keyword are filters
* applied on top of an already-visible list, not a prerequisite to seeing one.
* Products already imported by the selected tenant are badged rather than
* hidden, because re-importing tops up stock and is a legitimate thing to do.
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
*
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
* where — and nothing is written. There is no merchant to choose because
* nothing lands anywhere, so the page is the rail, the search and the grid
* and nothing else. The per-product Add is gone from here: stocking one
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
* Catalogue.
*
* - **From spreadsheet** is writing, and it cannot work without a target —
* the import endpoint rejects a row with no `tenantid` or `locationid`. So
* the merchant and outlet pickers live in that mode, where the question is
* actually being asked, rather than sitting above a grid that never uses
* them.
*
* The grid, the filters, the cards and the detail drawer are the shared
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
* same catalogue.
*/
export function GlobalCataloguePage() {
const [params, setParams] = useSearchParams();
const queryClient = useQueryClient();
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
const locationid = Number(params.get('locationid') ?? 0) || undefined;
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [selected, setSelected] = useState<CatalogueProduct | null>(null);
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
const { data: tenants } = useTenants();
const { data: brands } = useCatalogueBrands();
const {
data: products,
isLoading,
error,
} = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 });
const { data: importedRefs } = useImportedRefs(tenantid);
/** `brand:catalogueid` — both halves, because a bare id repeats across brands. */
const importedKeys = useMemo(() => {
const set = new Set<string>();
for (const ref of importedRefs ?? []) set.add(catalogueKey(ref));
return set;
}, [importedRefs]);
/**
* The outlets of the chosen merchant, read separately and deliberately.
*
* `getalltenants` is `SELECT * FROM tenants` with no join to
* `tenantlocations` (`tenantRepository.go:79-133`), so its `locationid` is the
* struct's zero value on every row. Seeding the destination from it — which
* this page used to do — meant the id was always 0 and every upload stayed
* blocked behind "pick a merchant" for a merchant already picked.
*/
const locations = useTenantLocations(tenantid);
const tenantOptions = useMemo(() => {
if (!tenants) return [];
const seen = new Map<number, { name: string; locationid: number }>();
const seen = new Map<number, string>();
for (const tenant of tenants as TenantInfo[]) {
if (!seen.has(tenant.tenantid)) {
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
}
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
}
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
}, [tenants]);
const brandOptions = useMemo(
() => [
{ value: '', label: 'All brands' },
...(brands ?? []).map((entry) => ({
value: entry.brand,
label: `${entry.brand} (${entry.product_count})`,
const locationOptions = useMemo(
() =>
(locations.data ?? []).map((outlet) => ({
value: String(outlet.locationid),
label: outlet.locationname || `Outlet ${outlet.locationid}`,
})),
],
[brands],
[locations.data],
);
const importMutation = useMutation({
mutationFn: (rows: ImportCatalogueProductRequest[]) => productsApi.importFromCatalogue(rows),
onSuccess: async () => {
// Both the imported-refs lookup and the store catalogue go stale together.
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
queryClient.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
setSelected(null);
},
});
function chooseTenant(value: string) {
const next = new URLSearchParams(params);
next.set('tenantid', value);
const tenant = (tenants as TenantInfo[] | undefined)?.find(
(entry) => String(entry.tenantid) === value,
);
if (tenant) next.set('locationid', String(tenant.locationid));
// Cleared rather than carried: the outlets for this merchant have not
// loaded yet, and keeping the previous one's would aim an upload at another
// merchant's shop.
next.delete('locationid');
setParams(next, { replace: true });
}
const hasTarget = Boolean(tenantid && locationid);
function chooseLocation(value: string) {
const next = new URLSearchParams(params);
next.set('locationid', value);
setParams(next, { replace: true });
}
/** Most merchants have one outlet; picking it from a list of one teaches nothing. */
useEffect(() => {
if (!tenantid || locationid) return;
const first = locations.data?.[0];
if (first) chooseLocation(String(first.locationid));
// `params` is read through the setter, so it is not a dependency here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantid, locationid, locations.data]);
return (
<VStack gap={3}>
<PageHeader
title="Global catalogue"
count={products ? `${products.length} shown` : undefined}
description="Browse the FMCG catalogue and stock a store from it — or upload the tenant's own product list instead."
description="Read the FMCG catalogue, or upload a merchant's own product list."
actions={
<HStack gap={1}>
<Button
label="From catalogue"
variant={mode === 'catalogue' ? 'primary' : 'secondary'}
icon={<PackagePlus size={15} />}
onClick={() => setMode('catalogue')}
/>
<Button
label="From spreadsheet"
variant={mode === 'sheet' ? 'primary' : 'secondary'}
icon={<Upload size={15} />}
onClick={() => setMode('sheet')}
/>
</HStack>
<SegmentedControl
label="What to do"
value={mode}
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
size="sm"
>
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
</SegmentedControl>
}
/>
{/* Which store is being stocked. Every import call needs both ids, so this
is asked once, up front, rather than per product. */}
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader title="Stocking" />
<div className="form-grid">
<Selector
label="Tenant"
options={tenantOptions}
value={tenantid ? String(tenantid) : ''}
onChange={chooseTenant}
placeholder="Select tenant…"
hasSearch
/>
<TextInput
label="Location id"
value={locationid ? String(locationid) : ''}
onChange={(value) => {
const next = new URLSearchParams(params);
next.set('locationid', value);
setParams(next, { replace: true });
}}
description="Defaults to the tenant's first outlet."
/>
</div>
{!hasTarget ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
Pick a tenant before importing — every import call is scoped to a tenant and a
location, and the backend rejects one without both.
</Text>
) : null}
</VStack>
</Card>
{mode === 'sheet' ? (
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Products"
action={
<HStack gap={1} wrap="wrap">
<div style={{ width: 190 }}>
<Selector
label="Brand"
isLabelHidden
options={brandOptions}
value={brand}
onChange={setBrand}
placeholder="All brands"
hasSearch
size="sm"
/>
</div>
<div style={{ width: 240 }}>
<TextInput
label="Search catalogue"
isLabelHidden
value={keyword}
onChange={setKeyword}
placeholder="Search catalogue…"
hasClear
size="sm"
/>
</div>
</HStack>
}
/>
<DataState
isLoading={isLoading}
error={error}
isEmpty={(products ?? []).length === 0}
emptyTitle="Nothing matches those filters"
emptyDescription="Clear the brand or keyword to see the full catalogue."
>
<div className="card-grid">
{(products ?? []).map((product) => {
const isImported = importedKeys.has(catalogueKey(product));
return (
<Card key={`${product.brand}:${product.id}`} padding={0} elevation="low">
<VStack gap={1} padding={2}>
<div
style={{
aspectRatio: '1',
background: 'var(--color-slate-50)',
borderRadius: 12,
display: 'grid',
placeItems: 'center',
overflow: 'hidden',
}}
>
{product.images?.[0] ? (
<img
src={product.images[0]}
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<PackagePlus size={26} style={{ color: 'var(--color-slate-300)' }} />
)}
</div>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold" maxLines={2}>
{product.product_name}
</Text>
<Text
type="body"
size="xsm"
color="secondary"
style={{ fontFamily: 'var(--font-mono)' }}
>
{product.brand} · {product.size ?? '—'}
</Text>
</VStack>
<HStack justify="between" align="center">
{/* A RANGE, not a price. The store sets the real one at import. */}
<Text type="body" size="sm" color="secondary">
{product.price_range ?? '—'}
</Text>
{isImported ? (
<Badge variant="success" label="Imported" icon={<Check size={11} />} />
) : null}
</HStack>
<Button
label={isImported ? 'Top up stock' : 'Add to store'}
variant={isImported ? 'secondary' : 'primary'}
size="sm"
width="100%"
isDisabled={!hasTarget}
onClick={() => setSelected(product)}
/>
</VStack>
</Card>
);
})}
</div>
</DataState>
<VStack gap={2}>
{/* Only here. An upload is written against one merchant and one
outlet, and the endpoint refuses a row without both. */}
<Card padding={0} elevation="low">
<VStack gap={2} padding={2}>
<SectionHeader
title="Uploading for"
note="Every row is written against one merchant and one outlet."
/>
<div className="form-grid">
<Selector
label="Merchant"
options={tenantOptions}
value={tenantid ? String(tenantid) : ''}
onChange={chooseTenant}
placeholder="Select merchant…"
hasSearch
/>
<Selector
label="Outlet"
options={locationOptions}
value={locationid ? String(locationid) : ''}
onChange={chooseLocation}
placeholder={
!tenantid
? 'Choose a merchant first…'
: locations.isLoading
? 'Loading outlets…'
: locationOptions.length === 0
? 'This merchant has no outlets'
: 'Select outlet…'
}
isDisabled={!tenantid || locationOptions.length === 0}
hasSearch={locationOptions.length > 6}
/>
</div>
</VStack>
</Card>
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
</VStack>
)}
{selected && hasTarget ? (
<ImportDrawer
product={selected}
tenantid={tenantid as number}
locationid={locationid as number}
isSaving={importMutation.isPending}
error={importMutation.error}
onClose={() => setSelected(null)}
onSubmit={(row) => importMutation.mutate([row])}
) : (
<CatalogueBrowser
tenantid={undefined}
locationid={undefined}
categoryOptions={[]}
actionLabel="Add to store"
isReadOnly
/>
) : null}
)}
</VStack>
);
}

View File

@@ -37,11 +37,20 @@ const EMPTY: FormState = {
primarycontact: '',
primaryemail: '',
locationname: '',
categoryid: '1',
// No default category. `utils/getappcategories` supplies the real list and
// this used to pre-select the first id regardless of what the merchant sells.
categoryid: '',
address: '',
suburb: '',
city: 'Coimbatore',
state: 'Tamil Nadu',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
};
@@ -90,6 +99,8 @@ export function OnboardTenantPage() {
form.locationname.trim() !== '' &&
form.address.trim() !== '' &&
form.city.trim() !== '' &&
form.state.trim() !== '' &&
form.categoryid !== '' &&
form.postcode.trim() !== '';
function handleSubmit(event: FormEvent) {
@@ -101,7 +112,9 @@ export function OnboardTenantPage() {
primarycontact: form.primarycontact.trim(),
primaryemail: form.primaryemail.trim(),
locationname: form.locationname.trim(),
categoryid: Number(form.categoryid),
// `Number('')` is NaN, which serialises to null and is not what the
// backend means by "uncategorised" — 0 is.
categoryid: Number(form.categoryid) || 0,
address: form.address.trim(),
suburb: form.suburb.trim(),
city: form.city.trim(),

View File

@@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { IndianRupee, PackageCheck, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
import { IndianRupee, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
import { DataState } from '@/components/DataState';
import { Freshness } from '@/components/Freshness';
import { KpiCard } from '@/components/KpiCard';
@@ -226,13 +226,13 @@ export function StoreDetailPage() {
icon={<TriangleAlert size={15} />}
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
/>
<KpiCard
label="Catalogue"
value={tenant ? 'Linked' : '—'}
note="stocked per branch"
tone="neutral"
icon={<PackageCheck size={15} />}
/>
{/* No "Catalogue — Linked" tile.
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
tenant row came back at all — it described the tenant existing, not
the catalogue. `products/getimportedcatalogueproducts` could give a
real imported count here; until something calls it, a tile that is
always green is worse than no tile. */}
</div>
<VStack gap={1.5}>

View File

@@ -180,11 +180,15 @@ export function StoresPage() {
return (
<VStack gap={3}>
{/* No `isLive` here. Both of this page's reads — `useTenants` and
`useTenantsByApproval` — use the `stable` query options: a 5-minute
staleTime and no refetchInterval. The pill claimed a freshness the page
does not have. The pages that keep it (Console, Sales, Counters, store
detail) poll on a real interval. */}
<PageHeader
title="Stores"
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
description="Every merchant on the platform, the branches under them, and how each one is performing."
isLive
actions={
<Button
label="Onboard tenant"

View File

@@ -1,280 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Check, DownloadCloud, Info } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import {
useCatalogueBrands,
useCatalogueProducts,
useImportedRefs,
useTenantCategories,
} from '@/queries/hooks';
import { Drawer } from './Drawer';
import { MediaPill, ProductCard } from './ProductCard';
/**
* Import from the global catalogue, without leaving Inventory.
*
* A drawer rather than a page because importing is one move inside the pricing
* job, not a destination: you come here because a product is missing from the
* list behind, and you want to go straight back to it.
*
* Two things follow the old console because it had them right, and one
* deliberately does not:
*
* - **One click imports.** No confirmation. Import is reversible in the sense
* that matters — the product lands unpriced and unreleased, so nothing
* reaches a till until someone prices it.
* - **The "Imported" badge is keyed on `brand:catalogueid`.** The old console
* compared a catalogue id against tenant PRODUCT ids, which are different
* number spaces, so its badge was effectively random. `getimportedcatalogueproducts`
* returns exactly the right pairs and it fetched them two lines above without
* ever reading them.
*/
export function CatalogueImportDrawer({
tenantid,
locationid,
onClose,
}: {
tenantid: number;
locationid: number;
onClose: () => void;
}) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [categoryid, setCategoryid] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
useEffect(() => {
const id = setTimeout(() => setDebounced(keyword.trim()), 400);
return () => clearTimeout(id);
}, [keyword]);
const brands = useCatalogueBrands();
const categories = useTenantCategories(tenantid);
const imported = useImportedRefs(tenantid);
const products = useCatalogueProducts({
...(brand ? { brand } : {}),
...(debounced ? { keyword: debounced } : {}),
pagesize: 48,
});
const importedKeys = useMemo(() => {
const set = new Set((imported.data ?? []).map(catalogueKey));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
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 }),
]);
},
});
async function run(product: CatalogueProduct) {
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised. The backend makes this field
// optional for exactly that reason: "an unclassified product is visibly
// unfinished, while a wrongly classified one looks done and is only
// found by someone browsing the wrong aisle."
categoryid: Number(categoryid) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing — the product lands in the
// "Not ready" list behind this drawer, which is where a price is set.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
setJustImported((set) => new Set(set).add(key));
} finally {
setBusy(null);
}
}
const brandOptions = useMemo(
() => [
{ value: '', label: 'All brands' },
...(brands.data ?? []).map((entry) => ({
value: entry.brand,
label: `${entry.brand.replace('brand_', '').toUpperCase()} (${entry.product_count})`,
})),
],
[brands.data],
);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
const rows = products.data ?? [];
return (
<Drawer
title="Add products"
subtitle="Pick what you sell from the global catalogue. Prices are set afterwards."
width={920}
isBare
onClose={onClose}
>
<VStack gap={1.5} padding={2} style={{ flex: 'none' }}>
<HStack gap={1} wrap="wrap">
<div style={{ width: 200 }}>
<Selector
label="Brand"
isLabelHidden
size="sm"
value={brand}
onChange={setBrand}
placeholder="All brands"
options={brandOptions}
/>
</div>
<div style={{ width: 220 }}>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search the catalogue…"
hasClear
/>
</div>
<div style={{ width: 210 }}>
<Selector
label="Category to import into"
isLabelHidden
size="sm"
value={categoryid}
onChange={setCategoryid}
placeholder="Leave uncategorised"
options={categoryOptions}
/>
</div>
</HStack>
<HStack gap={1} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
<Info size={13} />
</span>
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Imported products arrive with no price and are not released to your shops. They
appear under <strong>Not ready</strong> behind this panel, where you set the price.
</Text>
</HStack>
</VStack>
<div className="import-scroll" style={{ padding: 16, paddingTop: 0 }}>
{products.isLoading ? (
<Text type="body" size="sm" color="secondary">
Loading the catalogue…
</Text>
) : rows.length === 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
No products match your selection
</Text>
<Text type="body" size="sm" color="secondary">
Try a different brand, or clear the search.
</Text>
</VStack>
) : (
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
const images = product.images ?? [];
return (
<ProductCard
key={key}
images={images}
title={product.product_name}
identifier={product.product_sku ?? `ID: ${product.id}`}
left={product.size || '—'}
right={product.price_range || '—'}
mediaBadge={
<MediaPill label={product.brand.replace('brand_', '')} color="var(--color-brand)" />
}
{...(isImported
? {
mediaStatus: (
<MediaPill label="In your list" color="#10b981" isSolid />
),
}
: {})}
action={
isImported ? (
<Button
label="Added"
variant="secondary"
size="sm"
icon={<Check size={12} />}
isDisabled
width="full"
/>
) : (
<Button
label={busy === key ? 'Adding…' : 'Add to my products'}
variant="primary"
size="sm"
icon={<DownloadCloud size={12} />}
isDisabled={busy !== null}
width="full"
onClick={() => void run(product)}
/>
)
}
/>
);
})}
</div>
)}
{/* The photo count the import silently drops. 90 of Nestlé's 123
products carry more than one image and only the first survives. */}
{rows.some((product) => (product.images?.length ?? 0) > 1) ? (
<Text
type="body"
size="xsm"
style={{ color: 'var(--color-ink-4)', display: 'block', paddingTop: 12 }}
>
Products with several photos import only the first one.
</Text>
) : null}
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { useTenantCategories } from '@/queries/hooks';
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
import { useBranchScope } from './BranchScope';
/**
* The Store Admin's half of the catalogue: browse it, add to your own products.
*
* A thin wrapper over the shared browser, and deliberately thin. What is
* particular to this workspace is only the scope — the merchant is whoever is
* signed in — and one click imports, because a merchant adding a product to
* their own list has no pricing decision to make yet: the product lands under
* "Not ready" and is priced there.
*/
export function CataloguePanel() {
const { tenantid, current, branches } = useBranchScope();
/**
* Import writes against one outlet, which the backend requires.
*
* With "All branches" selected there is no single destination, so the first
* is used — and that is not a fudge: `PublishProduct` releases tenant-wide,
* so the outlet on the import row decides where the product row is filed,
* not who can sell it.
*/
const locationid = current?.locationid ?? branches[0]?.locationid;
const categories = useTenantCategories(tenantid);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
return (
<CatalogueBrowser
tenantid={tenantid || undefined}
locationid={locationid}
categoryOptions={categoryOptions}
actionLabel="Add to my products"
alignWithTabs
{...(!tenantid || !locationid
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
: {})}
/>
);
}

View File

@@ -1,369 +0,0 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react';
/**
* The product card, matched to the old console's.
*
* The proportions and treatment are lifted deliberately rather than reinvented,
* because staff already read them: a **160px white media band** with the image
* inset 12px and `object-contain`, a two-line bold name, the SKU in mono
* underneath, a hairline, then one line of meta with a figure right-aligned,
* then a single full-width action.
*
* `mix-blend-multiply` is the detail that makes it look right and is easy to
* miss. Catalogue photography is shot on white, so without it every card
* carries a visible grey rectangle where the photo's background meets the card.
*/
export interface ProductCardProps {
images: string[];
title: string;
/** The SKU or catalogue id — mono, under the title. */
identifier?: string;
/** Left-hand meta, e.g. "Unit: 500g". */
left?: React.ReactNode;
/** Right-hand figure, e.g. a price. Mono and bold. */
right?: React.ReactNode;
/**
* One short line under the SKU — why this product cannot be sold.
*
* A pill can say "Not ready"; only a sentence can say which of the two
* things is missing, and that is the whole difference between a card the
* merchant can act on and one that just worries them.
*/
note?: string;
noteColor?: string;
/** A badge pinned to the top-left of the image. */
mediaBadge?: React.ReactNode;
/** A badge pinned to the top-right of the image. */
mediaStatus?: React.ReactNode;
action?: React.ReactNode;
onClick?: () => void;
}
export function ProductCard({
images,
title,
identifier,
note,
noteColor,
left,
right,
mediaBadge,
mediaStatus,
action,
onClick,
}: ProductCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
minWidth: 0,
borderRadius: 16,
border: `1px solid ${isHovered ? 'color-mix(in oklab, var(--color-brand) 40%, transparent)' : 'var(--color-line)'}`,
background: 'var(--color-surface)',
overflow: 'hidden',
cursor: onClick ? 'pointer' : 'default',
boxShadow: isHovered
? '0 8px 20px -6px rgb(16 24 40 / .12)'
: '0 1px 2px rgb(16 24 40 / .05)',
transition: 'box-shadow .2s, border-color .2s',
}}
>
<Media images={images} alt={title} isHovered={isHovered} badge={mediaBadge} status={mediaStatus} />
<div
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 8,
padding: 12,
minWidth: 0,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
title={title}
style={{
margin: 0,
fontSize: 14,
fontWeight: 700,
lineHeight: 1.35,
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-1)',
transition: 'color .2s',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</h4>
{identifier ? (
<p
style={{
margin: '2px 0 0',
fontSize: 12,
fontWeight: 600,
fontFamily: 'var(--font-mono)',
letterSpacing: '-.01em',
color: 'var(--color-ink-4)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{identifier}
</p>
) : null}
{note ? (
<p
style={{
margin: '4px 0 0',
fontSize: 11.5,
lineHeight: 1.45,
color: noteColor ?? 'var(--color-ink-4)',
}}
>
{note}
</p>
) : null}
</div>
<div
style={{
marginTop: 'auto',
paddingTop: 8,
borderTop: '1px solid var(--color-line)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{left !== undefined || right !== undefined ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
color: 'var(--color-ink-3)',
}}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{left}
</span>
<span
style={{
flex: 'none',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
color: 'var(--color-ink-1)',
}}
>
{right}
</span>
</div>
) : null}
{action}
</div>
</div>
</div>
);
}
/**
* The 160px media band.
*
* Arrows and dots appear only when there is more than one image, and only on
* hover — and every handler stops propagation, or paging through photos would
* open the card instead.
*/
function Media({
images,
alt,
isHovered,
badge,
status,
}: {
images: string[];
alt: string;
isHovered: boolean;
badge?: React.ReactNode;
status?: React.ReactNode;
}) {
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
const current = images[Math.min(index, images.length - 1)];
const hasMany = images.length > 1;
return (
<div
style={{
position: 'relative',
width: '100%',
height: 160,
flex: 'none',
padding: 12,
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
overflow: 'hidden',
}}
>
{current && !failed ? (
<img
src={current}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
// Catalogue shots are on white; without this the photo's ground
// shows as a grey rectangle inside the card.
mixBlendMode: 'multiply',
transform: isHovered ? 'scale(1.08)' : 'scale(1)',
transition: 'transform .7s ease-in-out',
}}
/>
) : (
<span style={{ color: 'var(--color-line)' }}>
<ImageOff size={44} />
</span>
)}
{badge ? <span style={{ position: 'absolute', top: 8, left: 8, zIndex: 2 }}>{badge}</span> : null}
{status ? <span style={{ position: 'absolute', top: 8, right: 8, zIndex: 2 }}>{status}</span> : null}
{hasMany ? (
<>
<Arrow
side="left"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value - 1 + images.length) % images.length);
}}
/>
<Arrow
side="right"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value + 1) % images.length);
}}
/>
<span
style={{
position: 'absolute',
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 3,
zIndex: 2,
}}
>
{images.slice(0, 8).map((url, position) => (
<span
key={url}
style={{
width: position === index ? 12 : 5,
height: 5,
borderRadius: 999,
background:
position === index ? 'var(--color-brand)' : 'var(--color-line)',
transition: 'width .2s',
}}
/>
))}
</span>
</>
) : null}
</div>
);
}
function Arrow({
side,
isVisible,
onClick,
}: {
side: 'left' | 'right';
isVisible: boolean;
onClick: (event: React.MouseEvent) => void;
}) {
return (
<button
type="button"
aria-label={side === 'left' ? 'Previous image' : 'Next image'}
onClick={onClick}
style={{
position: 'absolute',
[side]: 4,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
width: 26,
height: 26,
display: 'grid',
placeItems: 'center',
borderRadius: 999,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
opacity: isVisible ? 1 : 0,
transition: 'opacity .2s',
}}
>
{side === 'left' ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
);
}
/** The small uppercase pill the old console pins over a product image. */
export function MediaPill({
label,
color,
isSolid,
}: {
label: string;
color: string;
isSolid?: boolean;
}) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 999,
fontSize: 10.5,
fontWeight: 800,
letterSpacing: '.06em',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
background: isSolid ? color : `color-mix(in srgb, ${color} 12%, var(--color-surface))`,
color: isSolid ? '#fff' : color,
border: isSolid ? 'none' : `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
}}
>
{label}
</span>
);
}

View File

@@ -10,7 +10,6 @@ import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { CatalogueImportDrawer } from './CatalogueImportDrawer';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
@@ -40,14 +39,10 @@ import {
* the truth on its own. They are shown per row, not as a filter.
*/
export function ProductsPanel({
isImporting,
onCloseImport,
isUploading,
onCloseUpload,
}: {
/** Owned by InventoryPage, so its buttons can sit in the tab row. */
isImporting: boolean;
onCloseImport: () => void;
/** Owned by InventoryPage, so its button can sit in the tab row. */
isUploading: boolean;
onCloseUpload: () => void;
}) {
@@ -132,13 +127,6 @@ export function ProductsPanel({
onClose={onCloseUpload}
/>
) : null}
{isImporting && tenantid && branch ? (
<CatalogueImportDrawer
tenantid={tenantid}
locationid={branch.locationid}
onClose={onCloseImport}
/>
) : null}
{publishing && tenantid ? (
<PublishDialog
products={publishing}

View File

@@ -30,10 +30,11 @@ import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { CataloguePanel } from '../CataloguePanel';
import { ProductsPanel } from '../ProductsPanel';
import { branchLabel, count, money } from '../format';
type Tab = 'products' | 'stock' | 'requests';
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
/**
* Inventory — catalogue, stock and the approval queue.
@@ -55,7 +56,6 @@ export function InventoryPage() {
const [tab, setTab] = useState<Tab>('products');
// Owned here so the button can live in the tab row while the drawer it opens
// stays inside the panel that uses it.
const [isImporting, setImporting] = useState(false);
const [isUploading, setUploading] = useState(false);
const requests = useStockRequests(
@@ -73,9 +73,14 @@ export function InventoryPage() {
title="Inventory"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="What you sell, where it is stocked, and what the shops are asking you for."
/* The two product actions live here, not in the tab row, so their
position is fixed — they no longer appear and disappear as the tab
changes, and nothing below shifts sideways when they do.
/* Upload sheet lives here, not in the tab row, so its position is
fixed — it no longer appears and disappears as the tab changes, and
nothing below shifts sideways when it does.
"Import products" used to sit beside it and is gone: once browsing
the catalogue became a tab, the button was a second door to a room
already on the wall, and it read as a different action than the tab
it opened.
The "N awaiting approval" badge that used to sit here is gone: the
Stock requests tab already carries the same count, and a number
@@ -89,12 +94,6 @@ export function InventoryPage() {
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<BarAction
label="Import products"
icon={<DownloadCloud size={14} />}
isDisabled={!tenantid}
onClick={() => setImporting(true)}
/>
</HStack>
}
tabs={
@@ -102,13 +101,19 @@ export function InventoryPage() {
giving it a row of its own below. It only appears on that tab —
an action for a panel you are not looking at is a misfire waiting
to happen. */
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Products"
icon={<PackageSearch size={14} />}
isActive={tab === 'products'}
onClick={() => setTab('products')}
/>
<TabButton
label="Catalogue"
icon={<DownloadCloud size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
<TabButton
label="Stock requests"
icon={<ClipboardList size={14} />}
@@ -128,11 +133,11 @@ export function InventoryPage() {
{tab === 'products' ? (
<ProductsPanel
isImporting={isImporting}
onCloseImport={() => setImporting(false)}
isUploading={isUploading}
onCloseUpload={() => setUploading(false)}
/>
) : tab === 'catalogue' ? (
<CataloguePanel />
) : tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
) : (

View File

@@ -55,8 +55,15 @@ const EMPTY: FormState = {
contactno: '',
address: '',
suburb: '',
city: 'Coimbatore',
state: 'Tamil Nadu',
// Empty, not pre-filled.
//
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
// so a merchant anywhere else was submitted with the wrong city and state
// unless somebody noticed and cleared them. A default that is right most of
// the time is worse than a blank field, because it is only checked when it
// looks wrong.
city: '',
state: '',
postcode: '',
opentime: isoTime('08:00'),
closetime: isoTime('22:00'),

View File

@@ -110,7 +110,7 @@ export function ReportsPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Revenue"
icon={<IndianRupee size={14} />}

View File

@@ -184,7 +184,7 @@ export function SalesPage() {
</HStack>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Online Orders"
icon={<ShoppingCart size={14} />}

View File

@@ -125,7 +125,7 @@ export function UsersPage() {
/>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Store users"
icon={<UserCog size={14} />}
@@ -426,8 +426,16 @@ function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof T
/** Active is quiet, anything else is loud — an inactive account is the exception. */
function StatusChip({ status }: { status: string | undefined }) {
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
// green dot and the word "Active" beside an account whose state the API never
// reported — which is the one case where somebody most needs to look.
const isActive = (status ?? '').toLowerCase().startsWith('active');
const isUnknown = !status;
const colour = isUnknown
? 'var(--color-ink-4)'
: isActive
? 'var(--color-success, #10b981)'
: 'var(--color-ink-4)';
return (
<span
style={{
@@ -442,7 +450,7 @@ function StatusChip({ status }: { status: string | undefined }) {
<span
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
/>
{isActive ? 'Active' : (status ?? 'Inactive')}
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
</span>
);
}

View File

@@ -292,3 +292,19 @@ test('problems come back worst bucket first', () => {
assert.equal(list[0]?.bucket, 'now');
assert.equal(list.at(-1)?.bucket, 'look');
});
/*
* The regression this file exists to prevent repeating.
*
* `GET /pos/health/location` answers `{location_id, total, online, terminals}`.
* It was typed as a bare array, so the first real sign-in hit `raw.map is not a
* function` — with no error boundary in the app at the time, that rendered as a
* blank page on the Store Admin console.
*/
test('a non-array from the health endpoint reads as no heartbeat, not a crash', () => {
const wrapper = { location_id: '1', total: 2, online: 1, terminals: [till({}), till({})] };
const summary = summariseBranch(wrapper as unknown as PosTerminalHealth[], NOW);
assert.equal(summary.state, 'no-heartbeat');
assert.equal(summary.total, 0);
assert.equal(summary.terminals.length, 0);
});

View File

@@ -251,7 +251,10 @@ export function rankOf(state: TerminalState): number {
* averaging is exactly how that hour goes unnoticed.
*/
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
const terminals = raw.map((entry) => readTerminal(entry, now));
// Defensive because this is a board on the landing page: an unexpected shape
// from the health endpoint should read as "no heartbeat", not take the whole
// console down with it. It already did once — see `PosLocationHealth`.
const terminals = (Array.isArray(raw) ? raw : []).map((entry) => readTerminal(entry, now));
const worst = terminals.reduce<TerminalState>(
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),

View File

@@ -156,7 +156,7 @@ export function StoreProductsPage() {
)
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Catalogue"
icon={<PackageSearch size={14} />}

View File

@@ -209,15 +209,26 @@ function RoleChip({ label }: { label: string | undefined }) {
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
}}
>
{label || 'cashier'}
{/* Not defaulted to 'cashier' — a till account whose role the API did
not report is unknown, and guessing the lower-privilege one is still a
guess printed in a Role column. */}
{label || '—'}
</span>
);
}
/** A dot and a word, not a pill — the merchant's page reads the same way. */
function StatusChip({ status }: { status: string | undefined }) {
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
// green dot and the word "Active" beside an account whose state the API never
// reported — which is the one case where somebody most needs to look.
const isActive = (status ?? '').toLowerCase().startsWith('active');
const isUnknown = !status;
const colour = isUnknown
? 'var(--color-ink-4)'
: isActive
? 'var(--color-success, #10b981)'
: 'var(--color-ink-4)';
return (
<span
style={{
@@ -230,7 +241,7 @@ function StatusChip({ status }: { status: string | undefined }) {
}}
>
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
{isActive ? 'Active' : (status ?? 'Inactive')}
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
</span>
);
}

View File

@@ -654,3 +654,528 @@ main {
@container drawer (min-width: 900px) {
.import-scroll .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* ────────────────────────────────────────────────────────────────────────────
Global catalogue — the rail and the product card
────────────────────────────────────────────────────────────────────────────
Modelled on the storefront reference, translated to this console's tokens
rather than its palette: the same proportions and the same rhythm, in
Nearle's purple and the greys every other page uses. Lifting the reference's
orange would have made one page look like a different product.
──────────────────────────────────────────────────────────────────────────── */
/* The search + rail-toggle row, lifted level with the page's tab row.
42px is the measured distance between the two rows' centres. The bottom
margin then restores the 24px the grid should keep below the tab row —
without it the pull-up drags the whole grid up with it and the first row of
cards slides under the tabs. */
@media (min-width: 900px) {
.catalogue-controls {
margin-top: -42px;
margin-bottom: 18px;
position: relative;
z-index: 1;
}
}
.catalogue-layout {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
@media (min-width: 900px) {
/* The rail is fixed, the grid takes what is left — a rail that grows with the
window steals width from the photographs, which are the content. */
.catalogue-layout[data-rail='open'] {
grid-template-columns: 216px minmax(0, 1fr);
}
}
.catalogue-rail {
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
position: sticky;
top: 72px;
/* The clip lives here, on the element that owns the radius. */
overflow: hidden;
}
.rail-scroll {
padding: 6px;
max-height: calc(100dvh - 98px);
overflow-y: auto;
/* A thin, quiet scrollbar. The default is wide enough to read as a second
column beside a 216px rail. */
scrollbar-width: thin;
scrollbar-color: var(--color-line) transparent;
}
.rail-scroll::-webkit-scrollbar {
width: 6px;
}
.rail-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--color-line);
}
.rail-scroll::-webkit-scrollbar-track {
background: transparent;
}
.rail-all,
.rail-brand,
.rail-category,
.rail-more {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
border: none;
background: transparent;
border-radius: 9px;
cursor: pointer;
font: inherit;
text-align: left;
color: var(--color-ink-1);
}
.rail-all {
padding: 9px 10px;
font-size: 13.5px;
font-weight: 700;
}
.rail-brand {
padding: 8px 10px;
font-size: 13px;
font-weight: 600;
}
.rail-brand-name {
text-transform: capitalize;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-category {
padding: 5px 10px 5px 20px;
font-size: 12.5px;
color: var(--color-ink-3);
justify-content: flex-start;
}
.rail-all:hover,
.rail-brand:hover,
.rail-category:hover,
.rail-more:hover {
background: var(--color-surface-sunken);
}
.rail-all[data-active='true'],
.rail-brand[data-active='true'] {
background: color-mix(in oklab, var(--color-brand) 8%, transparent);
color: var(--color-brand);
}
.rail-category[data-active='true'] {
color: var(--color-brand);
font-weight: 600;
}
.rail-group[data-open='true'] {
background: color-mix(in oklab, var(--color-brand) 4%, transparent);
border-radius: 10px;
}
.rail-count {
flex: none;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--color-ink-4);
}
.rail-more {
padding: 8px 10px;
margin-top: 2px;
border-top: 1px solid var(--color-line);
border-radius: 0 0 9px 9px;
font-size: 12px;
font-weight: 600;
color: var(--color-brand);
justify-content: center;
}
.rail-skeleton {
height: 13px;
border-radius: 6px;
background: var(--color-surface-sunken);
}
/* ── The card ─────────────────────────────────────────────────────────────── */
.pcard {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--color-line);
border-radius: 14px;
background: var(--color-surface);
overflow: hidden;
transition: border-color .18s, box-shadow .18s;
}
.pcard:hover {
border-color: color-mix(in oklab, var(--color-brand) 38%, transparent);
box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22);
}
.pcard-media {
position: relative;
/* Flex, not grid, and the reason is not taste: a grid row is sized by its
content unless told otherwise, so an oversized child grows the row past the
container and a percentage max-height then resolves against the GROWN row —
which is why a 240px pack shot stayed 220px tall inside a 150px band. In a
flex container with a definite height, the child's percentage resolves
against that height. */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
/* A fixed band rather than a square. At `aspect-ratio: 1` the photograph took
more than half the card's height and pushed the name, the size and the
price below the fold of a grid row — the picture identifies the product,
but the text is what is read once it has. */
height: 150px;
padding: 0;
border: none;
border-bottom: 1px solid var(--color-line);
background: var(--color-surface);
cursor: pointer;
overflow: hidden;
}
.pcard-media img {
width: 100%;
height: 100%;
object-fit: contain;
/* Catalogue photography is shot on white; without this the photo's own
ground reads as a grey panel inside the card. */
mix-blend-mode: multiply;
transition: transform .5s ease;
}
.pcard:hover .pcard-media img {
transform: scale(1.06);
}
.pcard-noimage {
display: grid;
place-items: center;
color: var(--color-line);
}
/* The click target, under the controls rather than around them. */
.pcard-open {
position: absolute;
inset: 0;
z-index: 1;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
/* ── The photo switcher ───────────────────────────────────────────────────── */
.pcard-arrow {
position: absolute;
top: 50%;
z-index: 3;
display: grid;
place-items: center;
width: 26px;
height: 26px;
transform: translateY(-50%);
border: 1px solid var(--color-line);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-ink-3);
cursor: pointer;
opacity: 0;
transition: opacity .16s, background .16s, color .16s;
box-shadow: 0 2px 6px rgb(16 24 40 / .12);
}
.pcard-arrow.is-left { left: 6px; }
.pcard-arrow.is-right { right: 6px; }
/* Shown on hover, and on keyboard focus — otherwise the only way to reach a
product's other photos would be a mouse. */
.pcard:hover .pcard-arrow,
.pcard-arrow:focus-visible {
opacity: 1;
}
.pcard-arrow:hover {
background: var(--color-brand);
border-color: var(--color-brand);
color: #fff;
}
.pcard-dots {
position: absolute;
bottom: 7px;
left: 50%;
z-index: 3;
display: flex;
gap: 4px;
transform: translateX(-50%);
}
.pcard-dot {
width: 5px;
height: 5px;
padding: 0;
border: none;
border-radius: 999px;
background: var(--color-line);
cursor: pointer;
transition: width .2s, background .2s;
}
.pcard-dot[data-active] {
width: 13px;
background: var(--color-brand);
}
.pcard-owned {
position: absolute;
top: 8px;
padding: 3px 8px;
border-radius: 7px;
font-size: 10px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
pointer-events: none;
}
.pcard-owned {
right: 8px;
background: #059669;
color: #fff;
}
.pcard-rail {
position: absolute;
top: 8px;
right: 8px;
display: flex;
flex-direction: column;
gap: 6px;
opacity: 0;
transform: translateX(6px);
transition: opacity .18s, transform .18s;
pointer-events: none;
}
/* Hidden while the owned badge holds that corner — two things stacked there
is how a badge ends up unreadable. */
.pcard:hover .pcard-rail {
opacity: 1;
transform: none;
}
.pcard[data-imported] .pcard-rail {
display: none;
}
.pcard-railbtn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-surface);
border: 1px solid var(--color-line);
color: var(--color-ink-3);
box-shadow: 0 2px 6px rgb(16 24 40 / .1);
}
.pcard-body {
display: flex;
flex-direction: column;
flex: 1;
gap: 5px;
padding: 11px 12px 12px;
min-width: 0;
}
.pcard-name {
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
font: inherit;
/* Medium, not bold. In the reference the product name is the quiet line and
the photograph does the identifying — a grid of two dozen bold names reads
as a list of headlines. */
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: var(--color-ink-1);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pcard-name:hover {
color: var(--color-brand);
}
.pcard-meta {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 11.5px;
color: var(--color-ink-4);
}
.pcard-sku {
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pcard-size {
flex: none;
padding: 1px 6px;
border-radius: 6px;
background: var(--color-surface-sunken);
color: var(--color-ink-3);
}
.pcard-foot {
margin-top: auto;
padding-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pcard-price {
white-space: nowrap;
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-ink-1);
}
.pcard-photos {
flex: none;
font-size: 11px;
color: var(--color-ink-4);
}
/* ── The action ───────────────────────────────────────────────────────────────
An outlined pill that fills on hover, which is the reference's treatment and
has no equivalent among Astryx's four Button variants (primary, secondary,
ghost, destructive — all filled or transparent, none outlined). It is drawn
here rather than approximated with the wrong stock variant, in Nearle's
purple rather than the reference's orange: borrowing the palette would have
made one page look like a different product.
──────────────────────────────────────────────────────────────────────────── */
.pcard-action {
display: inline-flex;
align-items: center;
gap: 5px;
flex: none;
padding: 6px 11px;
border: 1px solid var(--color-line);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-ink-2, var(--color-ink-1));
font: inherit;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
}
.pcard-action:hover:not(:disabled) {
background: var(--color-brand);
border-color: var(--color-brand);
color: #fff;
}
.pcard-action:disabled {
cursor: default;
opacity: .55;
}
.pcard-action.is-added {
border-color: color-mix(in oklab, #059669 40%, transparent);
background: color-mix(in oklab, #059669 8%, transparent);
color: #047857;
cursor: default;
}
@media (min-width: 560px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1100px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 1400px) {
.catalogue-layout .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* ────────────────────────────────────────────────────────────────────────────
Demo-mode flag — development only, dropped from a production build
──────────────────────────────────────────────────────────────────────────── */
.demo-flag {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 4px 6px 4px 10px;
border: 1px solid #f0b429;
border-radius: 999px;
background: #fef6e4;
color: #8a5a00;
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
cursor: pointer;
}
.demo-flag-exit {
padding: 2px 7px;
border-radius: 999px;
background: #8a5a00;
color: #fff;
font-size: 10px;
font-weight: 700;
}
.demo-flag:hover .demo-flag-exit {
background: #6b4600;
}

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Theme } from '@astryxdesign/core';
import { App } from './App';
import { AuthProvider } from './auth/AuthContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import { nearleTheme } from './theme/nearle';
import './index.css';
@@ -40,7 +41,13 @@ createRoot(container).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
{/* The backstop. The per-page boundary in `AppShell` catches almost
everything; this one exists for what it cannot — a throw in a
shell, a provider or the router itself, which would otherwise
render an empty document with the cause only in the console. */}
<ErrorBoundary area="the console">
<App />
</ErrorBoundary>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>

View File

@@ -99,6 +99,23 @@ export function useCatalogueProducts(query: CatalogueQuery) {
});
}
/**
* The categories inside one brand's table.
*
* Brand-scoped because the backend reads them from that brand's own table
* (`catalogueController.go:38-45` refuses without one) — there is no
* catalogue-wide category list to ask for. That is why the rail nests
* categories under a brand rather than listing them at the top level.
*/
export function useCatalogueCategories(brand: string | undefined) {
return useQuery({
queryKey: queryKeys.catalogue.categories(brand ?? ''),
queryFn: () => catalogueApi.categories(brand as string),
enabled: Boolean(brand),
...stable,
});
}
export function useCatalogueBrands() {
return useQuery({
queryKey: queryKeys.catalogue.brands(),
@@ -118,19 +135,10 @@ export function useImportedRefs(tenantid: number | undefined) {
/* ── Products ────────────────────────────────────────────────────────────── */
export function useProductCategories(tenantid: number | undefined) {
return useQuery({
queryKey: queryKeys.products.categories(tenantid ?? 0),
queryFn: () => productsApi.categories(tenantid as number),
enabled: typeof tenantid === 'number' && tenantid > 0,
...stable,
});
}
/**
* The tenant's categories as they are actually used.
*
* Prefer this over `useProductCategories`, which reads a master table missing
* Prefer this over `products/getproductcategories`, the master table missing
* rows for live categoryids and hardcoded to `moduleid = 2`.
*/
export function useTenantCategories(tenantid: number | undefined) {
@@ -196,21 +204,6 @@ export function useOrderSummary(tenantid: number | undefined) {
});
}
/**
* Till health for one outlet.
*
* One request per branch — `locationid` is required and singular on the POS
* endpoints, so there is no tenant-wide call to reach for.
*/
export function usePosHealth(locationid: number | undefined) {
return useQuery({
queryKey: queryKeys.insights.posHealth(locationid ?? 0),
queryFn: () => insightsApi.posHealth(locationid as number),
enabled: typeof locationid === 'number' && locationid > 0,
...live,
});
}
/* ── Store Admin: per-branch fan-out ─────────────────────────────────────── */
/**

View File

@@ -25,6 +25,7 @@ export const queryKeys = {
products: (params: Record<string, unknown>) =>
[...queryKeys.catalogue.all, 'products', params] as const,
brands: () => [...queryKeys.catalogue.all, 'brands'] as const,
categories: (brand: string) => [...queryKeys.catalogue.all, 'categories', brand] as const,
importedRefs: (tenantid: number) =>
[...queryKeys.catalogue.all, 'imported', tenantid] as const,
product: (brand: string, sku: string) =>