backend integration started
This commit is contained in:
@@ -8,7 +8,10 @@
|
|||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --port 3100",
|
"preview": "vite preview --port 3100",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"dependencies": {
|
||||||
"@astryxdesign/core": "^0.4.5",
|
"@astryxdesign/core": "^0.4.5",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
}
|
}
|
||||||
|
|||||||
434
scripts/contract.mjs
Normal file
434
scripts/contract.mjs
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* The contract check.
|
||||||
|
*
|
||||||
|
* Signs in once, calls every endpoint the console reads, and reports what came
|
||||||
|
* back: the HTTP status, the envelope, whether `details` is an array or an
|
||||||
|
* object or null, how many rows, and — the part that matters — the keys on the
|
||||||
|
* first row against the keys `src/api/types.ts` says to expect.
|
||||||
|
*
|
||||||
|
* This exists because field-name drift is invisible until a real payload
|
||||||
|
* arrives, and it is the single most likely way connecting to the backend goes
|
||||||
|
* wrong. It has already happened once from fixtures alone: POS health returns
|
||||||
|
* `terminal_id` while the sales split returns `terminalid`, and an index
|
||||||
|
* signature on the type let the wrong one typecheck in silence.
|
||||||
|
*
|
||||||
|
* READ-ONLY. Nothing here writes. Every create, update and import is left to a
|
||||||
|
* person on a scratch tenant, because several of them are unscoped and one of
|
||||||
|
* them moves stock.
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* npm run contract (prompts for the password, hidden)
|
||||||
|
*
|
||||||
|
* Or, for CI, set NEARLE_EMAIL and NEARLE_PASSWORD in the environment. Neither
|
||||||
|
* is ever written into this file — see the note beside EMAIL below.
|
||||||
|
*
|
||||||
|
* Plain JavaScript on purpose: it runs with the node you already have, with no
|
||||||
|
* install step and no TypeScript loader in the way.
|
||||||
|
*
|
||||||
|
* The credentials are read from the environment and never printed, logged or
|
||||||
|
* written to a file. Put them in front of the command rather than in a script,
|
||||||
|
* and they stay out of your shell history if your shell is configured for it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
|
||||||
|
const BASE = process.env['NEARLE_API'] ?? 'https://fiesta.nearle.app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/web/pos`, not `/pos`.
|
||||||
|
*
|
||||||
|
* The `/v1/pos` group sits behind the terminal's session guard; the console's
|
||||||
|
* copies of the same reads are registered under `/v1/web/pos`. Sweeping the
|
||||||
|
* wrong one would report a surface the console never calls.
|
||||||
|
*/
|
||||||
|
const WEB = '/live/api/v1/web';
|
||||||
|
const POS = '/live/api/v1/web/pos';
|
||||||
|
const MOB = '/live/api/v1/mob';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account to sweep with.
|
||||||
|
*
|
||||||
|
* The email defaults because it is not a secret. The PASSWORD is never
|
||||||
|
* defaulted and never written into this file: a password in source is
|
||||||
|
* committed, synced to every machine that clones the repo, and survives in the
|
||||||
|
* history after it is changed. It is read from the environment if set, and
|
||||||
|
* otherwise typed at the prompt below, where it is not echoed and does not
|
||||||
|
* reach the shell history.
|
||||||
|
*/
|
||||||
|
const EMAIL = process.env['NEARLE_EMAIL'] ?? 'care@nearle.in';
|
||||||
|
|
||||||
|
/** Reads a line without echoing it. */
|
||||||
|
function askHidden(question) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||||
|
const onData = (char) => {
|
||||||
|
// Stop echoing everything except the newline that ends the answer.
|
||||||
|
if (char.toString() !== '\n' && char.toString() !== '\r' && char.toString() !== '\u0004') {
|
||||||
|
process.stdout.write('\u001b[2K\u001b[200D' + question + '*'.repeat(rl.line.length));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.stdin.on('data', onData);
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
process.stdin.off('data', onData);
|
||||||
|
rl.close();
|
||||||
|
process.stdout.write('\n');
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PASSWORD =
|
||||||
|
process.env['NEARLE_PASSWORD'] ?? (await askHidden(`Password for ${EMAIL}: `));
|
||||||
|
|
||||||
|
if (!PASSWORD) {
|
||||||
|
console.error('No password given — nothing to sign in with.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(path, init = {}) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(init.params ?? {})) {
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const query = search.toString();
|
||||||
|
const response = await fetch(`${BASE}${path}${query ? `?${query}` : ''}`, {
|
||||||
|
method: init.method ?? 'GET',
|
||||||
|
headers: init.body
|
||||||
|
? { Accept: 'application/json', 'Content-Type': 'application/json' }
|
||||||
|
: { Accept: 'application/json' },
|
||||||
|
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
||||||
|
});
|
||||||
|
let envelope = {};
|
||||||
|
try {
|
||||||
|
envelope = await response.json();
|
||||||
|
} catch {
|
||||||
|
envelope = { message: 'not JSON' };
|
||||||
|
}
|
||||||
|
return { http: response.status, envelope };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A call that reports a dead host rather than crashing the run.
|
||||||
|
*
|
||||||
|
* A wrong `NEARLE_API`, a VPN that is not up, or one endpoint timing out should
|
||||||
|
* leave the other twenty-four results on screen — a stack trace at check four
|
||||||
|
* tells you nothing about checks five to twenty-five.
|
||||||
|
*/
|
||||||
|
async function attempt(path, init = {}) {
|
||||||
|
try {
|
||||||
|
return await call(path, init);
|
||||||
|
} catch (cause) {
|
||||||
|
return {
|
||||||
|
http: 0,
|
||||||
|
envelope: { status: false, message: `could not reach the server (${String(cause)})` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sign in ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const login = await attempt(`${WEB}/users/applogin`, {
|
||||||
|
method: 'POST',
|
||||||
|
// `configid` is not optional: the lookup is `WHERE authname = ? AND configid = ?`.
|
||||||
|
body: { authname: EMAIL, password: PASSWORD, configid: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (login.envelope.status !== true || !login.envelope.details) {
|
||||||
|
console.error(
|
||||||
|
`Sign-in failed — HTTP ${login.http}, code ${login.envelope.code}: ${login.envelope.message}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = login.envelope.details;
|
||||||
|
const tenantid = Number(me['tenantid'] ?? 0);
|
||||||
|
const locationid = Number(me['locationid'] ?? 0);
|
||||||
|
const issuperadmin = me['issuperadmin'] === true;
|
||||||
|
|
||||||
|
console.log('── signed in ─────────────────────────────────────────────');
|
||||||
|
console.log(`userid ${me['userid']} · roleid ${me['roleid']} · issuperadmin ${issuperadmin}`);
|
||||||
|
console.log(`tenantid ${tenantid} · locationid ${locationid} · ${me['locationname'] ?? '—'}`);
|
||||||
|
console.log('login keys:', Object.keys(me).sort().join(', '));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tenant and a branch to probe the scoped endpoints with.
|
||||||
|
*
|
||||||
|
* A super admin has neither of their own, so one is borrowed from the platform
|
||||||
|
* list. Override with NEARLE_TENANT / NEARLE_LOCATION to aim at a specific one.
|
||||||
|
*/
|
||||||
|
let probeTenant = Number(process.env['NEARLE_TENANT'] ?? 0) || tenantid;
|
||||||
|
let probeLocation = Number(process.env['NEARLE_LOCATION'] ?? 0) || locationid;
|
||||||
|
|
||||||
|
if (!probeTenant) {
|
||||||
|
const tenants = await attempt(`${WEB}/tenants/getalltenants`, {
|
||||||
|
params: { pageno: 1, pagesize: 1 },
|
||||||
|
});
|
||||||
|
probeTenant = Number(tenants.envelope.details?.[0]?.['tenantid'] ?? 0);
|
||||||
|
}
|
||||||
|
if (probeTenant && !probeLocation) {
|
||||||
|
const locations = await attempt(`${WEB}/tenants/gettenantlocations`, {
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
});
|
||||||
|
probeLocation = Number(locations.envelope.details?.[0]?.['locationid'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`probing with tenantid ${probeTenant} · locationid ${probeLocation}\n`);
|
||||||
|
|
||||||
|
/* ── What we expect ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keys each row should carry, taken from `src/api/types.ts`.
|
||||||
|
*
|
||||||
|
* Only the ones the console actually reads are listed — a backend that returns
|
||||||
|
* MORE than this is fine and normal, and is reported as extras rather than as a
|
||||||
|
* failure. What matters is anything missing.
|
||||||
|
*/
|
||||||
|
const CHECKS = [
|
||||||
|
{
|
||||||
|
name: 'tenants/getalltenants',
|
||||||
|
path: `${WEB}/tenants/getalltenants`,
|
||||||
|
params: { pageno: 1, pagesize: 5 },
|
||||||
|
expect: ['tenantid', 'tenantname', 'locationid', 'locationname', 'status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tenants/search?pending',
|
||||||
|
path: `${WEB}/tenants/search`,
|
||||||
|
params: { status: 'pending' },
|
||||||
|
expect: ['tenantid', 'tenantname'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tenants/gettenantlocations',
|
||||||
|
path: `${WEB}/tenants/gettenantlocations`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['locationid', 'tenantid', 'locationname', 'status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'utils/getappcategories',
|
||||||
|
path: `${WEB}/utils/getappcategories`,
|
||||||
|
expect: ['categoryid', 'categoryname'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orders/getlocationsummary',
|
||||||
|
path: `${WEB}/orders/getlocationsummary`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['locationid', 'locationname', 'total', 'delivered', 'cancelled'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orders/getordersummary',
|
||||||
|
path: `${WEB}/orders/getordersummary`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['total', 'delivered', 'cancelled'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'catalogue/getbrands',
|
||||||
|
path: `${WEB}/catalogue/getbrands`,
|
||||||
|
expect: ['brand', 'product_count'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'catalogue/getproducts',
|
||||||
|
path: `${WEB}/catalogue/getproducts`,
|
||||||
|
params: { pageno: 1, pagesize: 5 },
|
||||||
|
expect: ['id', 'brand', 'product_name'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getimportedcatalogueproducts',
|
||||||
|
path: `${WEB}/products/getimportedcatalogueproducts`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['brand', 'catalogueid'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getproductcategories',
|
||||||
|
path: `${WEB}/products/getproductcategories`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['categoryid', 'categoryname'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/gettenantcategories',
|
||||||
|
path: `${WEB}/products/gettenantcategories`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['categoryid', 'categoryname'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getlocationproducts',
|
||||||
|
path: `${WEB}/products/getlocationproducts`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['productid', 'productname', 'price', 'publishedat', 'status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getallproducts',
|
||||||
|
path: `${WEB}/products/getallproducts`,
|
||||||
|
params: { tenantid: probeTenant, pageno: 1, pagesize: 5 },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['productid', 'productname'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getstockstatement',
|
||||||
|
path: `${WEB}/products/getstockstatement`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['productid', 'opening', 'credit', 'debit', 'closing'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getstockrequests',
|
||||||
|
path: `${WEB}/products/getstockrequests`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['requestid', 'productid', 'qty', 'status'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'products/getsaletemplate',
|
||||||
|
path: `${WEB}/products/getsaletemplate`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['tenantid', 'locations', 'products'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'customers/gettenantcustomers',
|
||||||
|
path: `${WEB}/customers/gettenantcustomers`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation, pageno: 1, pagesize: 5 },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['customerid', 'firstname', 'contactno'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orders/getorders',
|
||||||
|
path: `${WEB}/orders/tenant/getorders`,
|
||||||
|
params: {
|
||||||
|
tenantid: probeTenant,
|
||||||
|
locationid: probeLocation,
|
||||||
|
fromdate: isoDaysAgo(30),
|
||||||
|
todate: isoDaysAgo(0),
|
||||||
|
pageno: 1,
|
||||||
|
pagesize: 5,
|
||||||
|
},
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['orderheaderid', 'orderstatus'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deliveries/getdeliveries',
|
||||||
|
path: `${WEB}/deliveries/getdeliveries`,
|
||||||
|
params: {
|
||||||
|
tenantid: probeTenant,
|
||||||
|
locationid: probeLocation,
|
||||||
|
fromdate: isoDaysAgo(30),
|
||||||
|
todate: isoDaysAgo(0),
|
||||||
|
pageno: 1,
|
||||||
|
pagesize: 5,
|
||||||
|
},
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['orderheaderid', 'orderstatus'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pos/sales/summary',
|
||||||
|
path: `${POS}/sales/summary`,
|
||||||
|
params: { locationid: probeLocation, fromdate: isoDaysAgo(7), todate: isoDaysAgo(0) },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['billcount', 'grosssales', 'taxcollected'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pos/sales',
|
||||||
|
path: `${POS}/sales`,
|
||||||
|
params: { locationid: probeLocation, pageno: 0, pagesize: 5 },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['bills', 'total'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pos/health/location',
|
||||||
|
path: `${POS}/health/location`,
|
||||||
|
params: { location_id: probeLocation },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['total', 'online', 'terminals'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tenants/getposusers',
|
||||||
|
path: `${WEB}/tenants/getposusers`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['users', 'location_id'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tenants/getstaffs (MOB)',
|
||||||
|
path: `${MOB}/tenants/getstaffs`,
|
||||||
|
params: { tenantid: probeTenant },
|
||||||
|
needs: 'tenant',
|
||||||
|
expect: ['userid', 'rolename', 'firstname'],
|
||||||
|
},
|
||||||
|
{ name: 'tenants/posroles', path: `${WEB}/tenants/posroles`, expect: ['role_id', 'role'] },
|
||||||
|
{
|
||||||
|
name: 'tenants/getstaffshifts',
|
||||||
|
path: `${WEB}/tenants/getstaffshifts`,
|
||||||
|
params: { tenantid: probeTenant, locationid: probeLocation },
|
||||||
|
needs: 'location',
|
||||||
|
expect: ['shifts', 'location_id'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function isoDaysAgo(days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Run ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let mismatches = 0;
|
||||||
|
let unreachable = 0;
|
||||||
|
|
||||||
|
for (const check of CHECKS) {
|
||||||
|
if (check.needs === 'tenant' && !probeTenant) {
|
||||||
|
console.log(`SKIP ${check.name} — no tenant to probe with`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (check.needs === 'location' && !probeLocation) {
|
||||||
|
console.log(`SKIP ${check.name} — no location to probe with`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { http, envelope } = await attempt(check.path, { params: check.params });
|
||||||
|
const payload = envelope.details ?? envelope.data;
|
||||||
|
|
||||||
|
const shape = Array.isArray(payload)
|
||||||
|
? `array(${payload.length})`
|
||||||
|
: payload === null || payload === undefined
|
||||||
|
? 'null'
|
||||||
|
: typeof payload;
|
||||||
|
|
||||||
|
// The row to inspect: the first element of a list, or the object itself.
|
||||||
|
const row = Array.isArray(payload) ? payload[0] : payload;
|
||||||
|
const keys = row && typeof row === 'object' ? Object.keys(row) : [];
|
||||||
|
const missing = check.expect.filter((key) => !keys.includes(key));
|
||||||
|
|
||||||
|
const ok = envelope.status !== false && http < 400 && missing.length === 0;
|
||||||
|
if (!ok) mismatches += 1;
|
||||||
|
if (http >= 400 || envelope.status === false) unreachable += 1;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${ok ? 'OK ' : 'CHECK'} ${check.name.padEnd(38)} http ${http} · code ${envelope.code ?? '—'} · ${shape}`,
|
||||||
|
);
|
||||||
|
if (envelope.status === false || http >= 400) {
|
||||||
|
console.log(` message: ${envelope.message ?? '(none)'}`);
|
||||||
|
}
|
||||||
|
if (missing.length > 0 && keys.length > 0) {
|
||||||
|
console.log(` MISSING: ${missing.join(', ')}`);
|
||||||
|
console.log(` got: ${keys.sort().join(', ')}`);
|
||||||
|
}
|
||||||
|
if (keys.length === 0 && shape !== 'null' && !Array.isArray(payload)) {
|
||||||
|
console.log(` payload: ${JSON.stringify(payload).slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`${CHECKS.length} checked · ${mismatches} to look at · ${unreachable} refused`);
|
||||||
|
console.log(
|
||||||
|
mismatches === 0
|
||||||
|
? 'Every endpoint answered in the shape the console expects.'
|
||||||
|
: 'Anything marked CHECK either refused the call or is missing a key the console reads.',
|
||||||
|
);
|
||||||
251
scripts/db.mjs
Normal file
251
scripts/db.mjs
Normal 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]();
|
||||||
@@ -25,7 +25,7 @@ export const catalogueApi = {
|
|||||||
* merged, paginated list — the list is never gated behind a brand selector.
|
* merged, paginated list — the list is never gated behind a brand selector.
|
||||||
*/
|
*/
|
||||||
products: (query: CatalogueQuery = {}) =>
|
products: (query: CatalogueQuery = {}) =>
|
||||||
api.get<CatalogueProduct[]>(`${WEB}/catalogue/getproducts`, {
|
api.list<CatalogueProduct>(`${WEB}/catalogue/getproducts`, {
|
||||||
brand: query.brand,
|
brand: query.brand,
|
||||||
category: query.category,
|
category: query.category,
|
||||||
keyword: query.keyword,
|
keyword: query.keyword,
|
||||||
@@ -34,11 +34,11 @@ export const catalogueApi = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
|
/** 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. */
|
/** Requires a brand — the backend reads categories from one brand's table. */
|
||||||
categories: (brand: string) =>
|
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
|
* One catalogue row in full — the fields the import leaves behind
|
||||||
@@ -56,7 +56,7 @@ export const catalogueApi = {
|
|||||||
* mixes brands.
|
* mixes brands.
|
||||||
*/
|
*/
|
||||||
importedRefs: (tenantid: number) =>
|
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. */
|
/** Key for the imported-refs lookup. Both halves, always. */
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* changes. Nothing else in the app calls `fetch`.
|
* changes. Nothing else in the app calls `fetch`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { demoResolve, isDemoActive, MISS } from '@/demo';
|
|
||||||
import type { FiestaEnvelope } from './types';
|
import type { FiestaEnvelope } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,9 +15,34 @@ import type { FiestaEnvelope } from './types';
|
|||||||
*/
|
*/
|
||||||
const API_BASE = import.meta.env['VITE_API_BASE'] ?? '/fiesta';
|
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 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.
|
* 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> {
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { method = 'GET', params, body, signal } = options;
|
const { method = 'GET', params, body, signal } = options;
|
||||||
|
|
||||||
// Demo mode short-circuits before any network call. In a production build
|
// There is exactly one path out of this function and it goes to `fetch`.
|
||||||
// `isDemoActive` is a constant `false`, so the bundler removes this branch
|
//
|
||||||
// and the fixtures with it.
|
// A fixture short-circuit used to sit here, gated on a sessionStorage flag.
|
||||||
if (import.meta.env.DEV && isDemoActive()) {
|
// It is gone: every screen in every workspace now shows what the API
|
||||||
const fixture = await demoResolve(path, params as Record<string, unknown> | undefined);
|
// returned or an error, and there is no longer a mode in which the console
|
||||||
if (fixture !== MISS) {
|
// shows something else convincingly.
|
||||||
// A beat of latency, so loading states are visible while working on them.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
|
||||||
return fixture as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${API_BASE}${path}${toQueryString(params)}`;
|
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) =>
|
get: <T>(path: string, params?: Record<string, QueryValue>, signal?: AbortSignal) =>
|
||||||
request<T>(path, { method: 'GET', params, signal }),
|
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>) =>
|
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
|
||||||
request<T>(path, { method: 'POST', body, params }),
|
request<T>(path, { method: 'POST', body, params }),
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export interface CustomerQuery {
|
|||||||
|
|
||||||
export const customersApi = {
|
export const customersApi = {
|
||||||
list: (query: CustomerQuery) =>
|
list: (query: CustomerQuery) =>
|
||||||
api.get<CustomerInfo[]>(`${WEB}/customers/gettenantcustomers`, {
|
api.list<CustomerInfo>(`${WEB}/customers/gettenantcustomers`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
keyword: query.keyword || undefined,
|
keyword: query.keyword || undefined,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
OrderRow,
|
OrderRow,
|
||||||
OrderSummary,
|
OrderSummary,
|
||||||
PosLocationHealth,
|
PosLocationHealth,
|
||||||
|
PosTerminalHealth,
|
||||||
PosSalesPage,
|
PosSalesPage,
|
||||||
PosSalesSummary,
|
PosSalesSummary,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -49,7 +50,7 @@ export const insightsApi = {
|
|||||||
* sending 0 silently gives page one rather than an error.
|
* sending 0 silently gives page one rather than an error.
|
||||||
*/
|
*/
|
||||||
orders: (query: OrderQuery) =>
|
orders: (query: OrderQuery) =>
|
||||||
api.get<OrderRow[]>(`${WEB}/orders/tenant/getorders`, {
|
api.list<OrderRow>(`${WEB}/orders/tenant/getorders`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
@@ -73,7 +74,7 @@ export const insightsApi = {
|
|||||||
* applocationid/userid/appuserid is present, so `tenantid` is required here.
|
* applocationid/userid/appuserid is present, so `tenantid` is required here.
|
||||||
*/
|
*/
|
||||||
deliveries: (query: OrderQuery) =>
|
deliveries: (query: OrderQuery) =>
|
||||||
api.get<DeliveryRow[]>(`${WEB}/deliveries/getdeliveries`, {
|
api.list<DeliveryRow>(`${WEB}/deliveries/getdeliveries`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
@@ -89,7 +90,7 @@ export const insightsApi = {
|
|||||||
|
|
||||||
/** Per-branch order totals for one tenant. `tenantid` is required. */
|
/** Per-branch order totals for one tenant. `tenantid` is required. */
|
||||||
locationSummary: (tenantid: number, range: DateRange = {}) =>
|
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 = {}) =>
|
revenueSummary: (tenantid: number, range: DateRange = {}) =>
|
||||||
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
api.get<OrderSummary>(`${WEB}/orders/getrevenuesummary`, { tenantid, ...range }),
|
||||||
@@ -153,7 +154,17 @@ export const insightsApi = {
|
|||||||
posSalesSummary: (locationid: number, range: DateRange = {}) =>
|
posSalesSummary: (locationid: number, range: DateRange = {}) =>
|
||||||
api.get<PosSalesSummary>(`${POS}/sales/summary`, { locationid, ...range }),
|
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) =>
|
* Till presence for one outlet — how many are online, how many bills are stranded.
|
||||||
api.get<PosLocationHealth>(`${POS}/health/location`, { location_id: locationid }),
|
*
|
||||||
|
* 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 : [])),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* account ends up holding a role that matches nothing.
|
* 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';
|
import type { PosRole, PosUser, StaffInfo, StaffShift } from './types';
|
||||||
|
|
||||||
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
/* ── Back-office staff ───────────────────────────────────────────────────── */
|
||||||
@@ -56,9 +56,16 @@ export const staffApi = {
|
|||||||
* and the backend says why that matters: "`app_roles` holds six rows for four
|
* 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
|
* back-office roles and most accounts carry an id absent from it, so any
|
||||||
* mapping written client-side is wrong."
|
* 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) =>
|
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),
|
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
|
||||||
|
|
||||||
@@ -106,9 +113,22 @@ export interface UpdatePosUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const posUsersApi = {
|
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) =>
|
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.
|
* The role picker's source.
|
||||||
@@ -117,7 +137,7 @@ export const posUsersApi = {
|
|||||||
* endpoint also carries the label and the description a person needs to
|
* endpoint also carries the label and the description a person needs to
|
||||||
* choose between them — and a third role would appear here first.
|
* 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.
|
* Create a till account.
|
||||||
@@ -126,13 +146,22 @@ export const posUsersApi = {
|
|||||||
* a listing never returns it: "An admin who loses it reissues rather than
|
* 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.
|
* 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) =>
|
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 : [])),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,24 +40,37 @@ export interface LocationProductQuery {
|
|||||||
export const productsApi = {
|
export const productsApi = {
|
||||||
/** A store's own catalogue — what is actually imported, with live stock. */
|
/** A store's own catalogue — what is actually imported, with live stock. */
|
||||||
locationProducts: (query: LocationProductQuery) =>
|
locationProducts: (query: LocationProductQuery) =>
|
||||||
api.get<Product[]>(`${WEB}/products/getlocationproducts`, {
|
api.list<Product>(`${WEB}/products/getlocationproducts`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
pageno: query.pageno ?? 0,
|
pageno: query.pageno ?? 0,
|
||||||
pagesize: query.pagesize ?? 50,
|
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) =>
|
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) =>
|
count: (tenantid: number) =>
|
||||||
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
|
api.get<{ count?: number }>(`${WEB}/products/getproductscount`, { tenantid }),
|
||||||
|
|
||||||
categories: (tenantid: number) =>
|
categories: (tenantid: number) =>
|
||||||
api.get<ProductCategory[]>(`${WEB}/products/getproductcategories`, { tenantid }),
|
api.list<ProductCategory>(`${WEB}/products/getproductcategories`, { tenantid }),
|
||||||
|
|
||||||
subCategories: (tenantid: number, categoryid: number) =>
|
subCategories: (tenantid: number, categoryid: number) =>
|
||||||
api.get<ProductSubCategory[]>(`${WEB}/products/getproductsubcategories`, {
|
api.list<ProductSubCategory>(`${WEB}/products/getproductsubcategories`, {
|
||||||
tenantid,
|
tenantid,
|
||||||
categoryid,
|
categoryid,
|
||||||
}),
|
}),
|
||||||
@@ -111,7 +124,7 @@ export const productsApi = {
|
|||||||
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
|
* categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
|
||||||
*/
|
*/
|
||||||
tenantCategories: (tenantid: number) =>
|
tenantCategories: (tenantid: number) =>
|
||||||
api.get<{ categoryid: number; categoryname: string }[]>(
|
api.list<{ categoryid: number; categoryname: string }>(
|
||||||
`${WEB}/products/gettenantcategories`,
|
`${WEB}/products/gettenantcategories`,
|
||||||
{ tenantid },
|
{ tenantid },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const stockApi = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
requests: (query: StockRequestQuery) =>
|
requests: (query: StockRequestQuery) =>
|
||||||
api.get<StockRequest[]>(`${WEB}/products/getstockrequests`, {
|
api.list<StockRequest>(`${WEB}/products/getstockrequests`, {
|
||||||
tenantid: query.tenantid,
|
tenantid: query.tenantid,
|
||||||
locationid: query.locationid,
|
locationid: query.locationid,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
@@ -115,7 +115,7 @@ export const stockApi = {
|
|||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}) =>
|
}) =>
|
||||||
api.get<StockStatementRow[]>(`${WEB}/products/getstockstatement`, {
|
api.list<StockStatementRow>(`${WEB}/products/getstockstatement`, {
|
||||||
tenantid: params.tenantid,
|
tenantid: params.tenantid,
|
||||||
locationid: params.locationid,
|
locationid: params.locationid,
|
||||||
subcategoryid: params.subcategoryid,
|
subcategoryid: params.subcategoryid,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const tenantsApi = {
|
|||||||
* endpoint rather than a tenant-scoped one.
|
* endpoint rather than a tenant-scoped one.
|
||||||
*/
|
*/
|
||||||
listAll: (query: TenantListQuery = {}) =>
|
listAll: (query: TenantListQuery = {}) =>
|
||||||
api.get<TenantInfo[]>(`${WEB}/tenants/getalltenants`, {
|
api.list<TenantInfo>(`${WEB}/tenants/getalltenants`, {
|
||||||
pageno: query.pageno ?? 1,
|
pageno: query.pageno ?? 1,
|
||||||
pagesize: query.pagesize ?? 100,
|
pagesize: query.pagesize ?? 100,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
@@ -83,14 +83,14 @@ export const tenantsApi = {
|
|||||||
* so this list is a queue to work from, not one to act on.
|
* so this list is a queue to work from, not one to act on.
|
||||||
*/
|
*/
|
||||||
byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
|
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. */
|
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
|
||||||
locations: (tenantid: number) =>
|
locations: (tenantid: number) =>
|
||||||
api.get<TenantLocation[]>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
|
api.list<TenantLocation>(`${WEB}/tenants/gettenantlocations`, { tenantid }),
|
||||||
|
|
||||||
search: (keyword: string) =>
|
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
|
* 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
|
* form and never called this, which means a category added to the master is
|
||||||
* invisible to onboarding until someone edits the frontend.
|
* invisible to onboarding until someone edits the frontend.
|
||||||
*/
|
*/
|
||||||
appCategories: () => api.get<AppCategory[]>(`${WEB}/utils/getappcategories`),
|
appCategories: () => api.list<AppCategory>(`${WEB}/utils/getappcategories`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export interface FiestaEnvelope<T> {
|
|||||||
code: number;
|
code: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
details?: T;
|
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. */
|
/** Present on the tenant login endpoints. */
|
||||||
tenantform?: boolean;
|
tenantform?: boolean;
|
||||||
}
|
}
|
||||||
@@ -469,8 +475,22 @@ export interface PosTerminalHealth {
|
|||||||
[key: string]: string | undefined;
|
[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`. */
|
/** `GET /pos/sales/summary` — verified against `models/pos.go` `PosSalesSummary`. */
|
||||||
export interface PosSalesSummary {
|
export interface PosSalesSummary {
|
||||||
|
|||||||
@@ -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 { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { HOME_ROUTE, type ConsoleRole, type SessionUser } from './roles';
|
import { HOME_ROUTE, type ConsoleRole, type SessionUser } from './roles';
|
||||||
import { clear, login as loginRequest, persist, restore } from './session';
|
import { clear, login as loginRequest, restore } from './session';
|
||||||
import { disableDemo, enableDemo } from '@/demo';
|
import { AuthContext, useAuth } from './context';
|
||||||
|
|
||||||
interface AuthContextValue {
|
/**
|
||||||
user: SessionUser | null;
|
* The context object and `useAuth` live in `./context`, which exports no
|
||||||
signIn: (email: string, password: string) => Promise<SessionUser>;
|
* components — see the note there. Both are re-exported from here so every
|
||||||
/** Development only — see `src/demo`. Absent from a production build. */
|
* existing import keeps working; the split is invisible to callers.
|
||||||
signInAsDemo: (session: SessionUser) => void;
|
*/
|
||||||
signOut: () => void;
|
export { useAuth } from './context';
|
||||||
}
|
export type { AuthContextValue } from './context';
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<SessionUser | null>(() => restore());
|
const [user, setUser] = useState<SessionUser | null>(() => restore());
|
||||||
@@ -23,38 +21,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return session;
|
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(() => {
|
const signOut = useCallback(() => {
|
||||||
if (import.meta.env.DEV) disableDemo();
|
|
||||||
clear();
|
clear();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ user, signIn, signInAsDemo, signOut }),
|
() => ({ user, signIn, signOut }),
|
||||||
[user, signIn, signInAsDemo, signOut],
|
[user, signIn, signOut],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext value={value}>{children}</AuthContext>;
|
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.
|
* Route guard.
|
||||||
*
|
*
|
||||||
|
|||||||
45
src/auth/context.ts
Normal file
45
src/auth/context.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createContext, use } from 'react';
|
||||||
|
import type { SessionUser } from './roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth context object, kept in a module that exports NO components.
|
||||||
|
*
|
||||||
|
* That separation is the whole point of this file, and it is not style.
|
||||||
|
*
|
||||||
|
* `createContext()` returns an object whose IDENTITY is the key React matches a
|
||||||
|
* provider to a consumer by. React Fast Refresh re-executes a module when it or
|
||||||
|
* its dependents change, and a module that exports components is a refresh
|
||||||
|
* boundary — so while `AuthContext` lived beside `AuthProvider`, a refresh could
|
||||||
|
* mint a NEW context object for the provider while consumers that were not
|
||||||
|
* re-executed still held the OLD one. The provider then publishes into a
|
||||||
|
* context nobody is reading, `use(AuthContext)` returns null, and `useAuth`
|
||||||
|
* throws `useAuth must be used inside <AuthProvider>` — from a component that
|
||||||
|
* is unmistakably inside it.
|
||||||
|
*
|
||||||
|
* That error is a lie about the component tree, which is what makes it so
|
||||||
|
* expensive: it sends you looking at `main.tsx`, where the nesting is correct
|
||||||
|
* and always was. A file with no component exports is not a refresh boundary,
|
||||||
|
* so the object created here is created once per page load and cannot be
|
||||||
|
* duplicated by an edit anywhere else.
|
||||||
|
*
|
||||||
|
* Rule for this file: no components, ever. Adding one re-arms the bug.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AuthContextValue {
|
||||||
|
user: SessionUser | null;
|
||||||
|
signIn: (email: string, password: string) => Promise<SessionUser>;
|
||||||
|
signOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const context = use(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useAuth must be used inside <AuthProvider>. If the tree looks right, the dev server ' +
|
||||||
|
'is serving a stale module — stop it, delete node_modules/.vite, and start it again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -77,6 +77,35 @@ export async function login(email: string, password: string): Promise<SessionUse
|
|||||||
return session;
|
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.
|
* The backend's own words, where they are usable, and ours where they are not.
|
||||||
*
|
*
|
||||||
|
|||||||
107
src/components/ErrorBoundary.tsx
Normal file
107
src/components/ErrorBoundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,19 @@ export interface PageHeaderProps {
|
|||||||
* The page frame header, built to KROW's `AdminPage` spec.
|
* The page frame header, built to KROW's `AdminPage` spec.
|
||||||
*
|
*
|
||||||
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
|
* 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
|
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
|
||||||
* product — a hairline rule beneath the whole thing at half-opacity, with 16px
|
* rhythm.
|
||||||
* of padding above it. 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) {
|
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
@@ -31,8 +41,6 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 16,
|
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 }}>
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
@@ -103,7 +111,7 @@ export function PageHeader({ title, count, description, actions, tabs, isLive }:
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
{tabs}
|
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
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 { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { ROLE_LABEL } from '@/auth/roles';
|
import { ROLE_LABEL } from '@/auth/roles';
|
||||||
@@ -87,29 +88,12 @@ export function AppShell({
|
|||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
|
const [isAssistantOpen, setIsAssistantOpen] = useState(true);
|
||||||
const [isNavOpen, setIsNavOpen] = useState(false);
|
const [isNavOpen, setIsNavOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(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
|
// 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
|
// finding the trigger again is a trap for anyone on a keyboard, and this one
|
||||||
// sits over the page rather than beside it.
|
// sits over the page rather than beside it.
|
||||||
@@ -181,6 +165,7 @@ export function AppShell({
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
||||||
{/* Below md the scope control moves into the navigation sheet. On a
|
{/* Below md the scope control moves into the navigation sheet. On a
|
||||||
390px phone the logo, the selector and the right-hand cluster add
|
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
|
up to 503px and push the header 113px past the viewport, so the
|
||||||
@@ -243,75 +228,21 @@ export function AppShell({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
{/* Search: 176px at rest, 256px once focused or filled. */}
|
{/* No global search.
|
||||||
<form
|
|
||||||
role="search"
|
There was a search box here with `⌘K` on it, and its `onSubmit`
|
||||||
className="show-from-md"
|
was `preventDefault()` and nothing else — it advertised a
|
||||||
onSubmit={(event) => event.preventDefault()}
|
console-wide search that did not exist and had no endpoint
|
||||||
style={{
|
behind it. The per-page search boxes on Sales, Users, Stores and
|
||||||
position: 'relative',
|
the catalogue are real and stay. A control that looks like it
|
||||||
alignItems: 'center',
|
works costs more trust than a missing one. */}
|
||||||
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>
|
|
||||||
|
|
||||||
{headerActions}
|
{headerActions}
|
||||||
|
|
||||||
<IconButton label="Notifications, 2 unread" hasUnread>
|
{/* No notification bell. It was labelled "2 unread" with the dot
|
||||||
<Bell size={16} />
|
painted unconditionally, for every user on every page, forever —
|
||||||
</IconButton>
|
and it had no click handler and no notifications endpoint behind
|
||||||
|
it anywhere in the API. */}
|
||||||
|
|
||||||
{/* Assistant button moved to floating pill */}
|
{/* Assistant button moved to floating pill */}
|
||||||
|
|
||||||
@@ -473,7 +404,14 @@ export function AppShell({
|
|||||||
row from md where it becomes a side column. */}
|
row from md where it becomes a side column. */}
|
||||||
<div className="admin-body app-gutter">
|
<div className="admin-body app-gutter">
|
||||||
<main style={{ minWidth: 0, flex: 1, padding: '24px 0 48px' }}>
|
<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>
|
</main>
|
||||||
|
|
||||||
{isAssistantOpen || isMobile ? (
|
{isAssistantOpen || isMobile ? (
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const CONTEXT: Record<
|
|||||||
> = {
|
> = {
|
||||||
'/nearle/stores': {
|
'/nearle/stores': {
|
||||||
page: 'Stores',
|
page: 'Stores',
|
||||||
title: 'Good afternoon',
|
title: 'Nearle Buddy',
|
||||||
greeting: 'Every tenant on the platform, and which of them have branches sitting idle.',
|
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: [
|
prompts: [
|
||||||
'Which tenants have no branches?',
|
'Which tenants have no branches?',
|
||||||
'Who onboarded most recently?',
|
'Who onboarded most recently?',
|
||||||
@@ -30,14 +30,14 @@ const CONTEXT: Record<
|
|||||||
page: 'Onboard tenant',
|
page: 'Onboard tenant',
|
||||||
title: 'Provisioning a tenant',
|
title: 'Provisioning a tenant',
|
||||||
greeting: 'Registering the enterprise also creates its primary Administrator account.',
|
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?'],
|
prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'],
|
||||||
},
|
},
|
||||||
'/nearle/catalogue': {
|
'/nearle/catalogue': {
|
||||||
page: 'Global catalogue',
|
page: 'Global catalogue',
|
||||||
title: 'Stocking a store',
|
title: 'Stocking a store',
|
||||||
greeting: 'The catalogue carries a price range, not a price — the store sets the real one.',
|
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: [
|
prompts: [
|
||||||
'Which products are already imported?',
|
'Which products are already imported?',
|
||||||
'Catalogue or spreadsheet?',
|
'Catalogue or spreadsheet?',
|
||||||
@@ -52,7 +52,7 @@ const CONTEXT: Record<
|
|||||||
page: 'Console',
|
page: 'Console',
|
||||||
title: 'Across your branches',
|
title: 'Across your branches',
|
||||||
greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.',
|
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: [
|
prompts: [
|
||||||
'Which branch is underperforming?',
|
'Which branch is underperforming?',
|
||||||
'Any tills not syncing?',
|
'Any tills not syncing?',
|
||||||
@@ -64,7 +64,7 @@ const CONTEXT: Record<
|
|||||||
page: 'Sales',
|
page: 'Sales',
|
||||||
title: 'Orders and deliveries',
|
title: 'Orders and deliveries',
|
||||||
greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.',
|
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: [
|
prompts: [
|
||||||
'Why is the cancel rate high?',
|
'Why is the cancel rate high?',
|
||||||
'Which orders are stuck?',
|
'Which orders are stuck?',
|
||||||
@@ -76,7 +76,7 @@ const CONTEXT: Record<
|
|||||||
page: 'Inventory',
|
page: 'Inventory',
|
||||||
title: 'Catalogue and stock',
|
title: 'Catalogue and stock',
|
||||||
greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.',
|
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: [
|
prompts: [
|
||||||
'What is waiting for approval?',
|
'What is waiting for approval?',
|
||||||
'Which products are unpublished?',
|
'Which products are unpublished?',
|
||||||
@@ -88,7 +88,7 @@ const CONTEXT: Record<
|
|||||||
page: 'Users & access',
|
page: 'Users & access',
|
||||||
title: 'Who can sign in',
|
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.',
|
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: [
|
prompts: [
|
||||||
'What is the difference between the two?',
|
'What is the difference between the two?',
|
||||||
'How do I add a cashier?',
|
'How do I add a cashier?',
|
||||||
@@ -100,10 +100,10 @@ const CONTEXT: Record<
|
|||||||
page: 'Reports',
|
page: 'Reports',
|
||||||
title: 'Revenue, sales and stock',
|
title: 'Revenue, sales and stock',
|
||||||
greeting: 'Fast and slow movers are the two lists that change what you order next.',
|
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: [
|
prompts: [
|
||||||
'Which products are slow moving?',
|
'Which products are slow moving?',
|
||||||
'Compare my three branches',
|
'Compare my branches',
|
||||||
'Online versus counter revenue',
|
'Online versus counter revenue',
|
||||||
'What is my inventory worth?',
|
'What is my inventory worth?',
|
||||||
],
|
],
|
||||||
@@ -114,7 +114,7 @@ const FALLBACK = {
|
|||||||
page: 'Console',
|
page: 'Console',
|
||||||
title: 'Good afternoon',
|
title: 'Good afternoon',
|
||||||
greeting: 'Ask about anything on this page.',
|
greeting: 'Ask about anything on this page.',
|
||||||
reading: 'Reading this page.',
|
reading: 'Would cover this page.',
|
||||||
prompts: ['What needs attention?', 'Summarise this page'],
|
prompts: ['What needs attention?', 'Summarise this page'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -295,6 +295,13 @@ export function AssistantPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<form
|
||||||
onSubmit={(event) => event.preventDefault()}
|
onSubmit={(event) => event.preventDefault()}
|
||||||
style={{
|
style={{
|
||||||
@@ -323,8 +330,9 @@ export function AssistantPanel({
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
placeholder={`Ask Buddy about ${context.page.toLowerCase()}…`}
|
disabled
|
||||||
aria-label="Ask Nearle Buddy"
|
placeholder="Not connected yet"
|
||||||
|
aria-label="Ask Nearle Buddy — not connected yet"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -343,18 +351,18 @@ export function AssistantPanel({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
disabled={draft.trim() === ''}
|
disabled
|
||||||
style={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
border: 0,
|
border: 0,
|
||||||
flex: 'none',
|
flex: 'none',
|
||||||
background: draft.trim() ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
|
background: 'var(--color-surface-sunken)',
|
||||||
color: draft.trim() ? '#fff' : 'var(--color-ink-4)',
|
color: 'var(--color-ink-4)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
cursor: draft.trim() ? 'pointer' : 'default',
|
cursor: 'default',
|
||||||
transition: 'background .2s',
|
transition: 'background .2s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -371,7 +379,7 @@ export function AssistantPanel({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Buddy reads this page's data. Check anything you act on.
|
Not connected yet — there is no assistant service behind this panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
1153
src/demo/fixtures.ts
1153
src/demo/fixtures.ts
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 { 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 { useAuth } from '@/auth/AuthContext';
|
||||||
import { HOME_ROUTE } from '@/auth/roles';
|
import { HOME_ROUTE } from '@/auth/roles';
|
||||||
import { PasswordSetupRequiredError } from '@/auth/session';
|
import {
|
||||||
import type { ConsoleRole, SessionUser } from '@/auth/roles';
|
MIN_PASSWORD_LENGTH,
|
||||||
|
PasswordSetupRequiredError,
|
||||||
/**
|
setInitialPassword,
|
||||||
* The demo panel is loaded lazily behind the build-time DEV constant, so in a
|
} from '@/auth/session';
|
||||||
* 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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-in — KROW's full-bleed auth archetype.
|
* 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.
|
* lands where their account says they belong.
|
||||||
*/
|
*/
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { user, signIn, signInAsDemo } = useAuth();
|
const { user, signIn } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [isRemembered, setIsRemembered] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
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 />;
|
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
@@ -49,19 +64,52 @@ export function LoginPage() {
|
|||||||
const session = await signIn(email, password);
|
const session = await signIn(email, password);
|
||||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(
|
// Not a failure — the first step. The account exists and has no password,
|
||||||
cause instanceof PasswordSetupRequiredError
|
// and the backend handed back the userid to set one against.
|
||||||
? 'This account has no password set yet. An administrator needs to set one before you can sign in.'
|
if (cause instanceof PasswordSetupRequiredError) {
|
||||||
: cause instanceof Error
|
setSetupUserid(cause.userid);
|
||||||
? cause.message
|
setNewPassword('');
|
||||||
: 'Sign-in failed',
|
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 {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
||||||
|
const canSetup =
|
||||||
|
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -81,24 +129,38 @@ export function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrandPanel />
|
<BrandPanel />
|
||||||
<FormPanel
|
{setupUserid === null ? (
|
||||||
email={email}
|
<FormPanel
|
||||||
password={password}
|
email={email}
|
||||||
isPasswordVisible={isPasswordVisible}
|
password={password}
|
||||||
isRemembered={isRemembered}
|
isPasswordVisible={isPasswordVisible}
|
||||||
error={error}
|
error={error}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
canSubmit={canSubmit}
|
canSubmit={canSubmit}
|
||||||
onEmail={setEmail}
|
onEmail={setEmail}
|
||||||
onPassword={setPassword}
|
onPassword={setPassword}
|
||||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||||
onToggleRemember={() => setIsRemembered((remembered) => !remembered)}
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
/>
|
||||||
onDemo={(role, session) => {
|
) : (
|
||||||
signInAsDemo(session);
|
<SetupPanel
|
||||||
navigate(HOME_ROUTE[role], { replace: true });
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -238,32 +300,26 @@ interface FormPanelProps {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
isPasswordVisible: boolean;
|
isPasswordVisible: boolean;
|
||||||
isRemembered: boolean;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
onEmail: (value: string) => void;
|
onEmail: (value: string) => void;
|
||||||
onPassword: (value: string) => void;
|
onPassword: (value: string) => void;
|
||||||
onToggleVisible: () => void;
|
onToggleVisible: () => void;
|
||||||
onToggleRemember: () => void;
|
|
||||||
onSubmit: (event: FormEvent) => void;
|
onSubmit: (event: FormEvent) => void;
|
||||||
onDemo: (role: ConsoleRole, session: SessionUser) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormPanel({
|
function FormPanel({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
isPasswordVisible,
|
isPasswordVisible,
|
||||||
isRemembered,
|
|
||||||
error,
|
error,
|
||||||
isBusy,
|
isBusy,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
onEmail,
|
onEmail,
|
||||||
onPassword,
|
onPassword,
|
||||||
onToggleVisible,
|
onToggleVisible,
|
||||||
onToggleRemember,
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onDemo,
|
|
||||||
}: FormPanelProps) {
|
}: FormPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
<div className="login-form" style={{ display: 'grid', placeItems: 'center' }}>
|
||||||
@@ -311,21 +367,7 @@ function FormPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleVisible}
|
onClick={onToggleVisible}
|
||||||
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
||||||
style={{
|
style={eyeButtonStyle}
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
{isPasswordVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -343,104 +385,23 @@ function FormPanel({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div
|
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
|
||||||
style={{
|
|
||||||
display: 'flex',
|
The checkbox was initialised true, toggled, rendered — and never
|
||||||
alignItems: 'center',
|
read: `handleSubmit` and `signIn(email, password)` never received
|
||||||
justifyContent: 'space-between',
|
it, so ticking or clearing it changed nothing about how long the
|
||||||
gap: 12,
|
session lasted. The link had no handler at all, and there is no
|
||||||
flexWrap: 'wrap',
|
password-reset endpoint in Fiesta to give it one. Both promised
|
||||||
}}
|
something the system does not do. */}
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<button
|
<ErrorNote message={error} />
|
||||||
type="button"
|
|
||||||
style={{
|
|
||||||
border: 0,
|
|
||||||
background: 'transparent',
|
|
||||||
padding: 0,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-brand)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
<SubmitButton
|
||||||
<div
|
canSubmit={canSubmit}
|
||||||
role="alert"
|
isBusy={isBusy}
|
||||||
style={{
|
busyLabel="Signing in…"
|
||||||
display: 'flex',
|
label="Continue"
|
||||||
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>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
@@ -456,16 +417,289 @@ function FormPanel({
|
|||||||
opens — you do not pick one.
|
opens — you do not pick one.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{DemoPanel ? (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DemoPanel onDemo={onDemo} />
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
Field
|
||||||
──────────────────────────────────────────────────────────────────────────── */
|
──────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
420
src/features/catalogue/CatalogueBrowser.tsx
Normal file
420
src/features/catalogue/CatalogueBrowser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
src/features/catalogue/CatalogueCard.tsx
Normal file
226
src/features/catalogue/CatalogueCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
337
src/features/catalogue/CatalogueDetailDrawer.tsx
Normal file
337
src/features/catalogue/CatalogueDetailDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/features/catalogue/CatalogueSidebar.tsx
Normal file
137
src/features/catalogue/CatalogueSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,294 +1,171 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { 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 { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Check, PackagePlus, Upload } from 'lucide-react';
|
import { PackageSearch, Upload } from 'lucide-react';
|
||||||
import { catalogueKey } from '@/api/catalogue';
|
import type { TenantInfo } from '@/api/types';
|
||||||
import { productsApi } from '@/api/products';
|
|
||||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
|
||||||
import { DataState } from '@/components/DataState';
|
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import {
|
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||||
useCatalogueBrands,
|
import { useTenantLocations, useTenants } from '@/queries/hooks';
|
||||||
useCatalogueProducts,
|
|
||||||
useImportedRefs,
|
|
||||||
useTenants,
|
|
||||||
} from '@/queries/hooks';
|
|
||||||
import { queryKeys } from '@/queries/keys';
|
|
||||||
import { ImportDrawer } from '../import/ImportDrawer';
|
|
||||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
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
|
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
|
||||||
* 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
|
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
|
||||||
* hidden, because re-importing tops up stock and is a legitimate thing to do.
|
* — 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() {
|
export function GlobalCataloguePage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||||
const locationid = Number(params.get('locationid') ?? 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 [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||||
|
|
||||||
const { data: tenants } = useTenants();
|
const { data: tenants } = useTenants();
|
||||||
const { data: brands } = useCatalogueBrands();
|
/**
|
||||||
const {
|
* The outlets of the chosen merchant, read separately and deliberately.
|
||||||
data: products,
|
*
|
||||||
isLoading,
|
* `getalltenants` is `SELECT * FROM tenants` with no join to
|
||||||
error,
|
* `tenantlocations` (`tenantRepository.go:79-133`), so its `locationid` is the
|
||||||
} = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 });
|
* struct's zero value on every row. Seeding the destination from it — which
|
||||||
const { data: importedRefs } = useImportedRefs(tenantid);
|
* 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.
|
||||||
/** `brand:catalogueid` — both halves, because a bare id repeats across brands. */
|
*/
|
||||||
const importedKeys = useMemo(() => {
|
const locations = useTenantLocations(tenantid);
|
||||||
const set = new Set<string>();
|
|
||||||
for (const ref of importedRefs ?? []) set.add(catalogueKey(ref));
|
|
||||||
return set;
|
|
||||||
}, [importedRefs]);
|
|
||||||
|
|
||||||
const tenantOptions = useMemo(() => {
|
const tenantOptions = useMemo(() => {
|
||||||
if (!tenants) return [];
|
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[]) {
|
for (const tenant of tenants as TenantInfo[]) {
|
||||||
if (!seen.has(tenant.tenantid)) {
|
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||||
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
|
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||||
}, [tenants]);
|
}, [tenants]);
|
||||||
|
|
||||||
const brandOptions = useMemo(
|
const locationOptions = useMemo(
|
||||||
() => [
|
() =>
|
||||||
{ value: '', label: 'All brands' },
|
(locations.data ?? []).map((outlet) => ({
|
||||||
...(brands ?? []).map((entry) => ({
|
value: String(outlet.locationid),
|
||||||
value: entry.brand,
|
label: outlet.locationname || `Outlet ${outlet.locationid}`,
|
||||||
label: `${entry.brand} (${entry.product_count})`,
|
|
||||||
})),
|
})),
|
||||||
],
|
[locations.data],
|
||||||
[brands],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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) {
|
function chooseTenant(value: string) {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
next.set('tenantid', value);
|
next.set('tenantid', value);
|
||||||
const tenant = (tenants as TenantInfo[] | undefined)?.find(
|
// Cleared rather than carried: the outlets for this merchant have not
|
||||||
(entry) => String(entry.tenantid) === value,
|
// loaded yet, and keeping the previous one's would aim an upload at another
|
||||||
);
|
// merchant's shop.
|
||||||
if (tenant) next.set('locationid', String(tenant.locationid));
|
next.delete('locationid');
|
||||||
setParams(next, { replace: true });
|
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 (
|
return (
|
||||||
<VStack gap={3}>
|
<VStack gap={3}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Global catalogue"
|
title="Global catalogue"
|
||||||
count={products ? `${products.length} shown` : undefined}
|
description="Read the FMCG catalogue, or upload a merchant's own product list."
|
||||||
description="Browse the FMCG catalogue and stock a store from it — or upload the tenant's own product list instead."
|
|
||||||
actions={
|
actions={
|
||||||
<HStack gap={1}>
|
<SegmentedControl
|
||||||
<Button
|
label="What to do"
|
||||||
label="From catalogue"
|
value={mode}
|
||||||
variant={mode === 'catalogue' ? 'primary' : 'secondary'}
|
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
|
||||||
icon={<PackagePlus size={15} />}
|
size="sm"
|
||||||
onClick={() => setMode('catalogue')}
|
>
|
||||||
/>
|
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
|
||||||
<Button
|
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
|
||||||
label="From spreadsheet"
|
</SegmentedControl>
|
||||||
variant={mode === 'sheet' ? 'primary' : 'secondary'}
|
|
||||||
icon={<Upload size={15} />}
|
|
||||||
onClick={() => setMode('sheet')}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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' ? (
|
{mode === 'sheet' ? (
|
||||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
<VStack gap={2}>
|
||||||
) : (
|
{/* Only here. An upload is written against one merchant and one
|
||||||
<VStack gap={1.5}>
|
outlet, and the endpoint refuses a row without both. */}
|
||||||
<SectionHeader
|
<Card padding={0} elevation="low">
|
||||||
title="Products"
|
<VStack gap={2} padding={2}>
|
||||||
action={
|
<SectionHeader
|
||||||
<HStack gap={1} wrap="wrap">
|
title="Uploading for"
|
||||||
<div style={{ width: 190 }}>
|
note="Every row is written against one merchant and one outlet."
|
||||||
<Selector
|
/>
|
||||||
label="Brand"
|
<div className="form-grid">
|
||||||
isLabelHidden
|
<Selector
|
||||||
options={brandOptions}
|
label="Merchant"
|
||||||
value={brand}
|
options={tenantOptions}
|
||||||
onChange={setBrand}
|
value={tenantid ? String(tenantid) : ''}
|
||||||
placeholder="All brands"
|
onChange={chooseTenant}
|
||||||
hasSearch
|
placeholder="Select merchant…"
|
||||||
size="sm"
|
hasSearch
|
||||||
/>
|
/>
|
||||||
</div>
|
<Selector
|
||||||
<div style={{ width: 240 }}>
|
label="Outlet"
|
||||||
<TextInput
|
options={locationOptions}
|
||||||
label="Search catalogue"
|
value={locationid ? String(locationid) : ''}
|
||||||
isLabelHidden
|
onChange={chooseLocation}
|
||||||
value={keyword}
|
placeholder={
|
||||||
onChange={setKeyword}
|
!tenantid
|
||||||
placeholder="Search catalogue…"
|
? 'Choose a merchant first…'
|
||||||
hasClear
|
: locations.isLoading
|
||||||
size="sm"
|
? 'Loading outlets…'
|
||||||
/>
|
: locationOptions.length === 0
|
||||||
</div>
|
? 'This merchant has no outlets'
|
||||||
</HStack>
|
: 'Select outlet…'
|
||||||
}
|
}
|
||||||
/>
|
isDisabled={!tenantid || locationOptions.length === 0}
|
||||||
|
hasSearch={locationOptions.length > 6}
|
||||||
<DataState
|
/>
|
||||||
isLoading={isLoading}
|
</div>
|
||||||
error={error}
|
</VStack>
|
||||||
isEmpty={(products ?? []).length === 0}
|
</Card>
|
||||||
emptyTitle="Nothing matches those filters"
|
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||||
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>
|
</VStack>
|
||||||
)}
|
) : (
|
||||||
|
<CatalogueBrowser
|
||||||
{selected && hasTarget ? (
|
tenantid={undefined}
|
||||||
<ImportDrawer
|
locationid={undefined}
|
||||||
product={selected}
|
categoryOptions={[]}
|
||||||
tenantid={tenantid as number}
|
actionLabel="Add to store"
|
||||||
locationid={locationid as number}
|
isReadOnly
|
||||||
isSaving={importMutation.isPending}
|
|
||||||
error={importMutation.error}
|
|
||||||
onClose={() => setSelected(null)}
|
|
||||||
onSubmit={(row) => importMutation.mutate([row])}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,20 @@ const EMPTY: FormState = {
|
|||||||
primarycontact: '',
|
primarycontact: '',
|
||||||
primaryemail: '',
|
primaryemail: '',
|
||||||
locationname: '',
|
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: '',
|
address: '',
|
||||||
suburb: '',
|
suburb: '',
|
||||||
city: 'Coimbatore',
|
// Empty, not pre-filled.
|
||||||
state: 'Tamil Nadu',
|
//
|
||||||
|
// 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: '',
|
postcode: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +99,8 @@ export function OnboardTenantPage() {
|
|||||||
form.locationname.trim() !== '' &&
|
form.locationname.trim() !== '' &&
|
||||||
form.address.trim() !== '' &&
|
form.address.trim() !== '' &&
|
||||||
form.city.trim() !== '' &&
|
form.city.trim() !== '' &&
|
||||||
|
form.state.trim() !== '' &&
|
||||||
|
form.categoryid !== '' &&
|
||||||
form.postcode.trim() !== '';
|
form.postcode.trim() !== '';
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent) {
|
function handleSubmit(event: FormEvent) {
|
||||||
@@ -101,7 +112,9 @@ export function OnboardTenantPage() {
|
|||||||
primarycontact: form.primarycontact.trim(),
|
primarycontact: form.primarycontact.trim(),
|
||||||
primaryemail: form.primaryemail.trim(),
|
primaryemail: form.primaryemail.trim(),
|
||||||
locationname: form.locationname.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(),
|
address: form.address.trim(),
|
||||||
suburb: form.suburb.trim(),
|
suburb: form.suburb.trim(),
|
||||||
city: form.city.trim(),
|
city: form.city.trim(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
|||||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
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 { DataState } from '@/components/DataState';
|
||||||
import { Freshness } from '@/components/Freshness';
|
import { Freshness } from '@/components/Freshness';
|
||||||
import { KpiCard } from '@/components/KpiCard';
|
import { KpiCard } from '@/components/KpiCard';
|
||||||
@@ -226,13 +226,13 @@ export function StoreDetailPage() {
|
|||||||
icon={<TriangleAlert size={15} />}
|
icon={<TriangleAlert size={15} />}
|
||||||
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
|
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
{/* No "Catalogue — Linked" tile.
|
||||||
label="Catalogue"
|
|
||||||
value={tenant ? 'Linked' : '—'}
|
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
|
||||||
note="stocked per branch"
|
tenant row came back at all — it described the tenant existing, not
|
||||||
tone="neutral"
|
the catalogue. `products/getimportedcatalogueproducts` could give a
|
||||||
icon={<PackageCheck size={15} />}
|
real imported count here; until something calls it, a tile that is
|
||||||
/>
|
always green is worse than no tile. */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
|
|||||||
@@ -180,11 +180,15 @@ export function StoresPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={3}>
|
<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
|
<PageHeader
|
||||||
title="Stores"
|
title="Stores"
|
||||||
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
||||||
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
||||||
isLive
|
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
label="Onboard tenant"
|
label="Onboard tenant"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
53
src/features/store-admin/CataloguePanel.tsx
Normal file
53
src/features/store-admin/CataloguePanel.tsx
Normal 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.' }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import type { Product } from '@/api/types';
|
|||||||
import { SectionHeader } from '@/components/SectionHeader';
|
import { SectionHeader } from '@/components/SectionHeader';
|
||||||
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
|
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
|
||||||
import { useBranchScope } from './BranchScope';
|
import { useBranchScope } from './BranchScope';
|
||||||
import { CatalogueImportDrawer } from './CatalogueImportDrawer';
|
|
||||||
import { count, money } from './format';
|
import { count, money } from './format';
|
||||||
import { ProductDrawer } from './ProductDrawer';
|
import { ProductDrawer } from './ProductDrawer';
|
||||||
import { PublishDialog } from './PublishDialog';
|
import { PublishDialog } from './PublishDialog';
|
||||||
@@ -40,14 +39,10 @@ import {
|
|||||||
* the truth on its own. They are shown per row, not as a filter.
|
* the truth on its own. They are shown per row, not as a filter.
|
||||||
*/
|
*/
|
||||||
export function ProductsPanel({
|
export function ProductsPanel({
|
||||||
isImporting,
|
|
||||||
onCloseImport,
|
|
||||||
isUploading,
|
isUploading,
|
||||||
onCloseUpload,
|
onCloseUpload,
|
||||||
}: {
|
}: {
|
||||||
/** Owned by InventoryPage, so its buttons can sit in the tab row. */
|
/** Owned by InventoryPage, so its button can sit in the tab row. */
|
||||||
isImporting: boolean;
|
|
||||||
onCloseImport: () => void;
|
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
onCloseUpload: () => void;
|
onCloseUpload: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -132,13 +127,6 @@ export function ProductsPanel({
|
|||||||
onClose={onCloseUpload}
|
onClose={onCloseUpload}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isImporting && tenantid && branch ? (
|
|
||||||
<CatalogueImportDrawer
|
|
||||||
tenantid={tenantid}
|
|
||||||
locationid={branch.locationid}
|
|
||||||
onClose={onCloseImport}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{publishing && tenantid ? (
|
{publishing && tenantid ? (
|
||||||
<PublishDialog
|
<PublishDialog
|
||||||
products={publishing}
|
products={publishing}
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ import { SectionHeader } from '@/components/SectionHeader';
|
|||||||
import { queryKeys } from '@/queries/keys';
|
import { queryKeys } from '@/queries/keys';
|
||||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||||
import { useBranchScope } from '../BranchScope';
|
import { useBranchScope } from '../BranchScope';
|
||||||
|
import { CataloguePanel } from '../CataloguePanel';
|
||||||
import { ProductsPanel } from '../ProductsPanel';
|
import { ProductsPanel } from '../ProductsPanel';
|
||||||
import { branchLabel, count, money } from '../format';
|
import { branchLabel, count, money } from '../format';
|
||||||
|
|
||||||
type Tab = 'products' | 'stock' | 'requests';
|
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory — catalogue, stock and the approval queue.
|
* Inventory — catalogue, stock and the approval queue.
|
||||||
@@ -55,7 +56,6 @@ export function InventoryPage() {
|
|||||||
const [tab, setTab] = useState<Tab>('products');
|
const [tab, setTab] = useState<Tab>('products');
|
||||||
// Owned here so the button can live in the tab row while the drawer it opens
|
// Owned here so the button can live in the tab row while the drawer it opens
|
||||||
// stays inside the panel that uses it.
|
// stays inside the panel that uses it.
|
||||||
const [isImporting, setImporting] = useState(false);
|
|
||||||
const [isUploading, setUploading] = useState(false);
|
const [isUploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const requests = useStockRequests(
|
const requests = useStockRequests(
|
||||||
@@ -73,9 +73,14 @@ export function InventoryPage() {
|
|||||||
title="Inventory"
|
title="Inventory"
|
||||||
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
count={selected === null ? `${branches.length} branches` : current?.locationname}
|
||||||
description="What you sell, where it is stocked, and what the shops are asking you for."
|
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
|
/* Upload sheet lives here, not in the tab row, so its position is
|
||||||
position is fixed — they no longer appear and disappear as the tab
|
fixed — it no longer appears and disappears as the tab changes, and
|
||||||
changes, and nothing below shifts sideways when they do.
|
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
|
The "N awaiting approval" badge that used to sit here is gone: the
|
||||||
Stock requests tab already carries the same count, and a number
|
Stock requests tab already carries the same count, and a number
|
||||||
@@ -89,12 +94,6 @@ export function InventoryPage() {
|
|||||||
isDisabled={!tenantid}
|
isDisabled={!tenantid}
|
||||||
onClick={() => setUploading(true)}
|
onClick={() => setUploading(true)}
|
||||||
/>
|
/>
|
||||||
<BarAction
|
|
||||||
label="Import products"
|
|
||||||
icon={<DownloadCloud size={14} />}
|
|
||||||
isDisabled={!tenantid}
|
|
||||||
onClick={() => setImporting(true)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
@@ -102,13 +101,19 @@ export function InventoryPage() {
|
|||||||
giving it a row of its own below. It only appears on that tab —
|
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
|
an action for a panel you are not looking at is a misfire waiting
|
||||||
to happen. */
|
to happen. */
|
||||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Products"
|
label="Products"
|
||||||
icon={<PackageSearch size={14} />}
|
icon={<PackageSearch size={14} />}
|
||||||
isActive={tab === 'products'}
|
isActive={tab === 'products'}
|
||||||
onClick={() => setTab('products')}
|
onClick={() => setTab('products')}
|
||||||
/>
|
/>
|
||||||
|
<TabButton
|
||||||
|
label="Catalogue"
|
||||||
|
icon={<DownloadCloud size={14} />}
|
||||||
|
isActive={tab === 'catalogue'}
|
||||||
|
onClick={() => setTab('catalogue')}
|
||||||
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Stock requests"
|
label="Stock requests"
|
||||||
icon={<ClipboardList size={14} />}
|
icon={<ClipboardList size={14} />}
|
||||||
@@ -128,11 +133,11 @@ export function InventoryPage() {
|
|||||||
|
|
||||||
{tab === 'products' ? (
|
{tab === 'products' ? (
|
||||||
<ProductsPanel
|
<ProductsPanel
|
||||||
isImporting={isImporting}
|
|
||||||
onCloseImport={() => setImporting(false)}
|
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
onCloseUpload={() => setUploading(false)}
|
onCloseUpload={() => setUploading(false)}
|
||||||
/>
|
/>
|
||||||
|
) : tab === 'catalogue' ? (
|
||||||
|
<CataloguePanel />
|
||||||
) : tab === 'requests' ? (
|
) : tab === 'requests' ? (
|
||||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -55,8 +55,15 @@ const EMPTY: FormState = {
|
|||||||
contactno: '',
|
contactno: '',
|
||||||
address: '',
|
address: '',
|
||||||
suburb: '',
|
suburb: '',
|
||||||
city: 'Coimbatore',
|
// Empty, not pre-filled.
|
||||||
state: 'Tamil Nadu',
|
//
|
||||||
|
// 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: '',
|
postcode: '',
|
||||||
opentime: isoTime('08:00'),
|
opentime: isoTime('08:00'),
|
||||||
closetime: isoTime('22:00'),
|
closetime: isoTime('22:00'),
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function ReportsPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Revenue"
|
label="Revenue"
|
||||||
icon={<IndianRupee size={14} />}
|
icon={<IndianRupee size={14} />}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export function SalesPage() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Online Orders"
|
label="Online Orders"
|
||||||
icon={<ShoppingCart size={14} />}
|
icon={<ShoppingCart size={14} />}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Store users"
|
label="Store users"
|
||||||
icon={<UserCog size={14} />}
|
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. */
|
/** Active is quiet, anything else is loud — an inactive account is the exception. */
|
||||||
function StatusChip({ status }: { status: string | undefined }) {
|
function StatusChip({ status }: { status: string | undefined }) {
|
||||||
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
|
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
|
||||||
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
|
// 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -442,7 +450,7 @@ function StatusChip({ status }: { status: string | undefined }) {
|
|||||||
<span
|
<span
|
||||||
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
|
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
|
||||||
/>
|
/>
|
||||||
{isActive ? 'Active' : (status ?? 'Inactive')}
|
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,3 +292,19 @@ test('problems come back worst bucket first', () => {
|
|||||||
assert.equal(list[0]?.bucket, 'now');
|
assert.equal(list[0]?.bucket, 'now');
|
||||||
assert.equal(list.at(-1)?.bucket, 'look');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ export function rankOf(state: TerminalState): number {
|
|||||||
* averaging is exactly how that hour goes unnoticed.
|
* averaging is exactly how that hour goes unnoticed.
|
||||||
*/
|
*/
|
||||||
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
|
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>(
|
const worst = terminals.reduce<TerminalState>(
|
||||||
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),
|
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function StoreProductsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
tabs={
|
tabs={
|
||||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
<HStack gap={0.5} wrap="wrap">
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Catalogue"
|
label="Catalogue"
|
||||||
icon={<PackageSearch size={14} />}
|
icon={<PackageSearch size={14} />}
|
||||||
|
|||||||
@@ -209,15 +209,26 @@ function RoleChip({ label }: { label: string | undefined }) {
|
|||||||
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A dot and a word, not a pill — the merchant's page reads the same way. */
|
/** A dot and a word, not a pill — the merchant's page reads the same way. */
|
||||||
function StatusChip({ status }: { status: string | undefined }) {
|
function StatusChip({ status }: { status: string | undefined }) {
|
||||||
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
|
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
|
||||||
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
|
// 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -230,7 +241,7 @@ function StatusChip({ status }: { status: string | undefined }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
|
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
|
||||||
{isActive ? 'Active' : (status ?? 'Inactive')}
|
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
525
src/index.css
525
src/index.css
@@ -654,3 +654,528 @@ main {
|
|||||||
@container drawer (min-width: 900px) {
|
@container drawer (min-width: 900px) {
|
||||||
.import-scroll .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { Theme } from '@astryxdesign/core';
|
import { Theme } from '@astryxdesign/core';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { AuthProvider } from './auth/AuthContext';
|
import { AuthProvider } from './auth/AuthContext';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { nearleTheme } from './theme/nearle';
|
import { nearleTheme } from './theme/nearle';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -40,7 +41,13 @@ createRoot(container).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<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>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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() {
|
export function useCatalogueBrands() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.catalogue.brands(),
|
queryKey: queryKeys.catalogue.brands(),
|
||||||
@@ -118,19 +135,10 @@ export function useImportedRefs(tenantid: number | undefined) {
|
|||||||
|
|
||||||
/* ── Products ────────────────────────────────────────────────────────────── */
|
/* ── 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.
|
* 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`.
|
* rows for live categoryids and hardcoded to `moduleid = 2`.
|
||||||
*/
|
*/
|
||||||
export function useTenantCategories(tenantid: number | undefined) {
|
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 ─────────────────────────────────────── */
|
/* ── Store Admin: per-branch fan-out ─────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const queryKeys = {
|
|||||||
products: (params: Record<string, unknown>) =>
|
products: (params: Record<string, unknown>) =>
|
||||||
[...queryKeys.catalogue.all, 'products', params] as const,
|
[...queryKeys.catalogue.all, 'products', params] as const,
|
||||||
brands: () => [...queryKeys.catalogue.all, 'brands'] as const,
|
brands: () => [...queryKeys.catalogue.all, 'brands'] as const,
|
||||||
|
categories: (brand: string) => [...queryKeys.catalogue.all, 'categories', brand] as const,
|
||||||
importedRefs: (tenantid: number) =>
|
importedRefs: (tenantid: number) =>
|
||||||
[...queryKeys.catalogue.all, 'imported', tenantid] as const,
|
[...queryKeys.catalogue.all, 'imported', tenantid] as const,
|
||||||
product: (brand: string, sku: string) =>
|
product: (brand: string, sku: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user