From 3545819be86b37af74a75d274ddcb4a068271741 Mon Sep 17 00:00:00 2001 From: abhishek Date: Wed, 26 Aug 2026 18:37:28 +0530 Subject: [PATCH] backend integration started --- package.json | 6 +- scripts/contract.mjs | 434 +++++++ scripts/db.mjs | 251 ++++ src/api/catalogue.ts | 8 +- src/api/client.ts | 73 +- src/api/customers.ts | 2 +- src/api/insights.ts | 23 +- src/api/people.ts | 47 +- src/api/products.ts | 23 +- src/api/stock.ts | 4 +- src/api/tenants.ts | 10 +- src/api/types.ts | 24 +- src/auth/AuthContext.tsx | 45 +- src/auth/context.ts | 45 + src/auth/session.ts | 29 + src/components/ErrorBoundary.tsx | 107 ++ src/components/PageHeader.tsx | 20 +- src/components/shell/AppShell.tsx | 108 +- src/components/shell/AssistantPanel.tsx | 44 +- src/demo/DemoPanel.tsx | 137 -- src/demo/accounts.ts | 69 - src/demo/fixtures.ts | 1153 ----------------- src/demo/index.ts | 300 ----- src/features/auth/LoginPage.tsx | 558 +++++--- src/features/catalogue/CatalogueBrowser.tsx | 420 ++++++ src/features/catalogue/CatalogueCard.tsx | 226 ++++ .../catalogue/CatalogueDetailDrawer.tsx | 337 +++++ src/features/catalogue/CatalogueSidebar.tsx | 137 ++ .../nearle-admin/import/ImportDrawer.tsx | 256 ---- .../pages/GlobalCataloguePage.tsx | 363 ++---- .../nearle-admin/pages/OnboardTenantPage.tsx | 21 +- .../nearle-admin/pages/StoreDetailPage.tsx | 16 +- .../nearle-admin/pages/StoresPage.tsx | 6 +- .../store-admin/CatalogueImportDrawer.tsx | 280 ---- src/features/store-admin/CataloguePanel.tsx | 53 + src/features/store-admin/ProductCard.tsx | 369 ------ src/features/store-admin/ProductsPanel.tsx | 14 +- .../store-admin/pages/InventoryPage.tsx | 33 +- .../store-admin/pages/OnboardBranchPage.tsx | 11 +- .../store-admin/pages/ReportsPage.tsx | 2 +- src/features/store-admin/pages/SalesPage.tsx | 2 +- src/features/store-admin/pages/UsersPage.tsx | 16 +- src/features/store-admin/posStatus.test.ts | 16 + src/features/store-admin/posStatus.ts | 5 +- .../store-user/pages/StoreProductsPage.tsx | 2 +- .../store-user/pages/StoreStaffPage.tsx | 19 +- src/index.css | 525 ++++++++ src/main.tsx | 9 +- src/queries/hooks.ts | 43 +- src/queries/keys.ts | 1 + 50 files changed, 3461 insertions(+), 3241 deletions(-) create mode 100644 scripts/contract.mjs create mode 100644 scripts/db.mjs create mode 100644 src/auth/context.ts create mode 100644 src/components/ErrorBoundary.tsx delete mode 100644 src/demo/DemoPanel.tsx delete mode 100644 src/demo/accounts.ts delete mode 100644 src/demo/fixtures.ts delete mode 100644 src/demo/index.ts create mode 100644 src/features/catalogue/CatalogueBrowser.tsx create mode 100644 src/features/catalogue/CatalogueCard.tsx create mode 100644 src/features/catalogue/CatalogueDetailDrawer.tsx create mode 100644 src/features/catalogue/CatalogueSidebar.tsx delete mode 100644 src/features/nearle-admin/import/ImportDrawer.tsx delete mode 100644 src/features/store-admin/CatalogueImportDrawer.tsx create mode 100644 src/features/store-admin/CataloguePanel.tsx delete mode 100644 src/features/store-admin/ProductCard.tsx diff --git a/package.json b/package.json index 7899743..d410f9a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview --port 3100", "typecheck": "tsc --noEmit", - "test": "tsx --test \"src/**/*.test.ts\"" + "test": "tsx --test \"src/**/*.test.ts\"", + "contract": "node scripts/contract.mjs", + "db": "node scripts/db.mjs", + "verify:live": "test ! -d src/demo && test $(grep -rl 'await fetch(' src | wc -l) -eq 1 && ! grep -rlq 'src/demo' src/ && echo \"clean: no fixture layer, one fetch, every screen reads the API\"" }, "dependencies": { "@astryxdesign/core": "^0.4.5", @@ -29,6 +32,7 @@ "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^5.0.4", "tailwindcss": "^4.3.3", + "tsx": "^4.20.3", "typescript": "^7.0.2", "vite": "^8.2.2" } diff --git a/scripts/contract.mjs b/scripts/contract.mjs new file mode 100644 index 0000000..54e66b9 --- /dev/null +++ b/scripts/contract.mjs @@ -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.', +); diff --git a/scripts/db.mjs b/scripts/db.mjs new file mode 100644 index 0000000..756d878 --- /dev/null +++ b/scripts/db.mjs @@ -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 + * 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 ', + '', + ' tables every table Hasura has tracked', + ' user show an account (never prints the password)', + ' setpw set a password on an account that has none', + ' sql " 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', - }} - /> - - ⌘K - - + {/* No global search. + + There was a search box here with `⌘K` on it, and its `onSubmit` + was `preventDefault()` and nothing else — it advertised a + console-wide search that did not exist and had no endpoint + behind it. The per-page search boxes on Sales, Users, Stores and + the catalogue are real and stay. A control that looks like it + works costs more trust than a missing one. */} {headerActions} - - - + {/* No notification bell. It was labelled "2 unread" with the dot + painted unconditionally, for every user on every page, forever — + and it had no click handler and no notifications endpoint behind + it anywhere in the API. */} {/* Assistant button moved to floating pill */} @@ -473,7 +404,14 @@ export function AppShell({ row from md where it becomes a side column. */}
- + {/* 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. */} + + +
{isAssistantOpen || isMobile ? ( diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx index 4f70d12..f63a352 100644 --- a/src/components/shell/AssistantPanel.tsx +++ b/src/components/shell/AssistantPanel.tsx @@ -16,9 +16,9 @@ const CONTEXT: Record< > = { '/nearle/stores': { page: 'Stores', - title: 'Good afternoon', + title: 'Nearle Buddy', greeting: 'Every tenant on the platform, and which of them have branches sitting idle.', - reading: 'Reading the tenant directory — branch counts, status and per-tenant performance.', + reading: 'Would cover the tenant directory — branch counts, status and per-tenant performance.', prompts: [ 'Which tenants have no branches?', 'Who onboarded most recently?', @@ -30,14 +30,14 @@ const CONTEXT: Record< page: 'Onboard tenant', title: 'Provisioning a tenant', greeting: 'Registering the enterprise also creates its primary Administrator account.', - reading: 'Reading the onboarding form — required fields, category and head-office address.', + reading: 'Would cover the onboarding form — required fields, category and head-office address.', prompts: ['What does provisioning create?', 'Which fields are required?', 'What happens next?'], }, '/nearle/catalogue': { page: 'Global catalogue', title: 'Stocking a store', greeting: 'The catalogue carries a price range, not a price — the store sets the real one.', - reading: 'Reading the global catalogue and what this tenant has already imported.', + reading: 'Would cover the global catalogue and what this tenant has already imported.', prompts: [ 'Which products are already imported?', 'Catalogue or spreadsheet?', @@ -52,7 +52,7 @@ const CONTEXT: Record< page: 'Console', title: 'Across your branches', greeting: 'App sales, counter sales and imported bills are counted separately — they live in different ledgers.', - reading: 'Reading every branch — revenue by channel, stock health, till status and what is waiting on you.', + reading: 'Would cover every branch — revenue by channel, stock health, till status and what is waiting on you.', prompts: [ 'Which branch is underperforming?', 'Any tills not syncing?', @@ -64,7 +64,7 @@ const CONTEXT: Record< page: 'Sales', title: 'Orders and deliveries', greeting: 'An app order and a counter bill are both sales, but only one of them has a delivery.', - reading: 'Reading orders, counter bills and delivery progress across your branches.', + reading: 'Would cover orders, counter bills and delivery progress across your branches.', prompts: [ 'Why is the cancel rate high?', 'Which orders are stuck?', @@ -76,7 +76,7 @@ const CONTEXT: Record< page: 'Inventory', title: 'Catalogue and stock', greeting: 'A product in the store catalogue does not mean stock on the shelf — that takes an approved request.', - reading: 'Reading your catalogue, per-branch stock levels and the stock requests waiting on you.', + reading: 'Would cover your catalogue, per-branch stock levels and the stock requests waiting on you.', prompts: [ 'What is waiting for approval?', 'Which products are unpublished?', @@ -88,7 +88,7 @@ const CONTEXT: Record< page: 'Users & access', title: 'Who can sign in', greeting: 'A till account and a console login are two different things — a cashier has no console access at all.', - reading: 'Reading your back-office directory and the till accounts at each branch.', + reading: 'Would cover your back-office directory and the till accounts at each branch.', prompts: [ 'What is the difference between the two?', 'How do I add a cashier?', @@ -100,10 +100,10 @@ const CONTEXT: Record< page: 'Reports', title: 'Revenue, sales and stock', greeting: 'Fast and slow movers are the two lists that change what you order next.', - reading: 'Reading revenue by channel and branch, product performance and stock movement.', + reading: 'Would cover revenue by channel and branch, product performance and stock movement.', prompts: [ 'Which products are slow moving?', - 'Compare my three branches', + 'Compare my branches', 'Online versus counter revenue', 'What is my inventory worth?', ], @@ -114,7 +114,7 @@ const FALLBACK = { page: 'Console', title: 'Good afternoon', greeting: 'Ask about anything on this page.', - reading: 'Reading this page.', + reading: 'Would cover this page.', prompts: ['What needs attention?', 'Summarise this page'], }; @@ -295,6 +295,13 @@ export function AssistantPanel({ ))}
+ {/* 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. */}
event.preventDefault()} style={{ @@ -323,8 +330,9 @@ export function AssistantPanel({ }} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - placeholder={`Ask Buddy about ${context.page.toLowerCase()}…`} - aria-label="Ask Nearle Buddy" + disabled + placeholder="Not connected yet" + aria-label="Ask Nearle Buddy — not connected yet" style={{ display: 'block', width: '100%', @@ -343,18 +351,18 @@ export function AssistantPanel({ - ); - })} - - - ); -} diff --git a/src/demo/accounts.ts b/src/demo/accounts.ts deleted file mode 100644 index d738df1..0000000 --- a/src/demo/accounts.ts +++ /dev/null @@ -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 = { - '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', - }, -}; diff --git a/src/demo/fixtures.ts b/src/demo/fixtures.ts deleted file mode 100644 index 71749ba..0000000 --- a/src/demo/fixtures.ts +++ /dev/null @@ -1,1153 +0,0 @@ -/** - * Demo fixtures. - * - * Shapes and values are copied from the live tenant so the screens read as the - * real thing rather than as lorem ipsum: R mart in RS Puram, the Aachi/Amul/ - * Cadbury catalogue, the 97.6% cancel rate. Anyone who knows the data will - * recognise it, which is the point — a demo that looks nothing like production - * teaches you nothing about production. - * - * DEV ONLY. See `src/demo/index.ts` for how this is gated and stripped. - */ - -import type { - CatalogueBrand, - CatalogueProduct, - CatalogueRef, - LocationOrderSummary, - OrderSummary, - Product, - ProductCategory, - TenantInfo, - TenantLocation, -} from '@/api/types'; - -export const DEMO_TENANTS: TenantInfo[] = [ - { - tenantid: 1135, - locationid: 1, - tenantname: 'R mart', - locationname: 'RS Puram', - companyname: 'R Mart Retail Pvt. Ltd.', - primaryemail: 'rmart@gmail.com', - primarycontact: '9879879876', - city: 'Coimbatore', - state: 'Tamil Nadu', - suburb: 'RS Puram', - postcode: '641002', - status: 'Active', - }, - { - tenantid: 1136, - locationid: 4, - tenantname: 'Kaveri Groceries', - locationname: 'Peelamedu', - companyname: 'Kaveri Retail Pvt. Ltd.', - primaryemail: 'admin@kaveri.com', - primarycontact: '9876543210', - city: 'Coimbatore', - state: 'Tamil Nadu', - suburb: 'Peelamedu', - postcode: '641004', - status: 'Active', - }, - { - tenantid: 1136, - locationid: 5, - tenantname: 'Kaveri Groceries', - locationname: 'Saibaba Colony', - companyname: 'Kaveri Retail Pvt. Ltd.', - primaryemail: 'admin@kaveri.com', - primarycontact: '9876543210', - city: 'Coimbatore', - state: 'Tamil Nadu', - status: 'Active', - }, - { - tenantid: 1137, - locationid: 8, - tenantname: 'Anbu Stores', - locationname: 'Gandhipuram', - companyname: 'Anbu Traders', - primaryemail: 'andiraj@gmail.com', - primarycontact: '7397177923', - city: 'Coimbatore', - state: 'Tamil Nadu', - status: 'InActive', - }, -]; - -export const DEMO_LOCATIONS: Record = { - 1135: [ - { - locationid: 1, - tenantid: 1135, - locationname: 'R mart — RS Puram', - email: 'rspuram@rmart.com', - contactno: '9879879876', - address: '240, DB Road', - suburb: 'RS Puram', - city: 'Coimbatore', - state: 'Tamil Nadu', - postcode: '641002', - opentime: '08:00', - closetime: '22:00', - deliveryradius: 5000, - deliverymins: 30, - status: 'Active', - }, - { - locationid: 2, - tenantid: 1135, - locationname: 'R mart — Peelamedu', - email: 'peelamedu@rmart.com', - contactno: '9879879877', - address: '18, Avinashi Road', - suburb: 'Peelamedu', - city: 'Coimbatore', - state: 'Tamil Nadu', - postcode: '641004', - opentime: '07:00', - closetime: '23:00', - deliveryradius: 6000, - deliverymins: 25, - status: 'Active', - }, - { - locationid: 3, - tenantid: 1135, - locationname: 'R mart — Saravanampatti', - email: 'saravanampatti@rmart.com', - contactno: '9879879878', - address: '5, Sathy Main Road', - suburb: 'Saravanampatti', - city: 'Coimbatore', - state: 'Tamil Nadu', - postcode: '641035', - opentime: '08:00', - closetime: '22:00', - deliveryradius: 4500, - deliverymins: 35, - status: 'Active', - }, - ], - 1136: [ - { - locationid: 4, - tenantid: 1136, - locationname: 'Kaveri — Peelamedu', - address: '12, Avinashi Road', - suburb: 'Peelamedu', - city: 'Coimbatore', - state: 'Tamil Nadu', - postcode: '641004', - opentime: '07:00', - closetime: '23:00', - deliveryradius: 7000, - deliverymins: 25, - status: 'Active', - }, - { - locationid: 5, - tenantid: 1136, - locationname: 'Kaveri — Saibaba Colony', - address: '88, Mettupalayam Road', - suburb: 'Saibaba Colony', - city: 'Coimbatore', - state: 'Tamil Nadu', - postcode: '641011', - opentime: '08:00', - closetime: '22:00', - deliveryradius: 4000, - deliverymins: 35, - status: 'Active', - }, - ], - 1137: [ - { - locationid: 8, - tenantid: 1137, - locationname: 'Anbu — Gandhipuram', - address: '5, Cross Cut Road', - city: 'Coimbatore', - state: 'Tamil Nadu', - postcode: '641012', - opentime: '09:00', - closetime: '21:00', - deliveryradius: 3000, - deliverymins: 40, - status: 'InActive', - }, - ], -}; - -/** Per-branch order totals — the shape `/orders/getlocationsummary` returns. */ -export const DEMO_LOCATION_SUMMARY: Record = { - 1135: [ - { locationid: 1, locationname: 'R mart — RS Puram', totalorders: 84, delivered: 1, cancelled: 82, revenue: 1350 }, - { locationid: 2, locationname: 'R mart — Peelamedu', totalorders: 268, delivered: 241, cancelled: 19, revenue: 58400 }, - { locationid: 3, locationname: 'R mart — Saravanampatti', totalorders: 142, delivered: 126, cancelled: 12, revenue: 27950 }, - ], - 1136: [ - { locationid: 4, locationname: 'Kaveri — Peelamedu', totalorders: 412, delivered: 388, cancelled: 11, revenue: 84250 }, - { locationid: 5, locationname: 'Kaveri — Saibaba Colony', totalorders: 196, delivered: 181, cancelled: 6, revenue: 39800 }, - ], - 1137: [{ locationid: 8, locationname: 'Anbu — Gandhipuram', totalorders: 0, delivered: 0, cancelled: 0, revenue: 0 }], -}; - -export const DEMO_ORDER_SUMMARY: Record = { - 1135: { totalorders: 84, delivered: 1, pending: 0, cancelled: 82, revenue: 1350 }, - 1136: { totalorders: 608, delivered: 569, pending: 22, cancelled: 17, revenue: 124050 }, - 1137: { totalorders: 0, delivered: 0, pending: 0, cancelled: 0, revenue: 0 }, -}; - -export const DEMO_BRANDS: CatalogueBrand[] = [ - { brand: 'aachi', product_count: 176 }, - { brand: 'amul', product_count: 122 }, - { brand: 'anil', product_count: 50 }, - { brand: 'cadbury', product_count: 110 }, - { brand: 'cavinkare', product_count: 45 }, - { brand: 'nestle', product_count: 123 }, - { brand: 'pepsico', product_count: 88 }, -]; - -/** - * Stand-in pack shots, inline so the demo renders with no network at all. - * Real catalogue images come from the S3 listing behind `productimages`. - */ -const BRAND_ART: Record = { - aachi: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23f2c94c%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23c0392b%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EAACHI%3C/text%3E%3C/svg%3E', - amul: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23f7d774%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23c9a227%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EAMUL%3C/text%3E%3C/svg%3E', - anil: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23dfe6e9%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23636e72%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EANIL%3C/text%3E%3C/svg%3E', - cadbury: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%238e5a2b%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%234b2e83%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3ECADBURY%3C/text%3E%3C/svg%3E', - cavinkare: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23e0bbe4%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%237d3c98%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3ECAVINKARE%3C/text%3E%3C/svg%3E', - nestle: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23e74c3c%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%238e2a1f%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3ENESTLE%3C/text%3E%3C/svg%3E', - pepsico: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%2374b9ff%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%230652dd%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EPEPSICO%3C/text%3E%3C/svg%3E', -}; - -function catalogueProduct( - id: number, - brand: string, - name: string, - size: string, - range: string, - category: string, -): CatalogueProduct { - return { - id, - brand, - product_name: name, - size, - price_range: range, - category, - product_sku: `${brand.toUpperCase()}-${id}`, - ...(BRAND_ART[brand] ? { images: [BRAND_ART[brand] as string] } : {}), - }; -} - -export const DEMO_CATALOGUE: CatalogueProduct[] = [ - catalogueProduct(43, 'aachi', 'Aachi Appalams 100g', '100g', '₹33-37', 'Snacks'), - catalogueProduct(89, 'aachi', 'Aachi Chilli Powder 100gm', '100gm', '₹31-39', 'Masala'), - catalogueProduct(7, 'aachi', 'Aachi Chicken Masala 100g', '100g', '₹23-27', 'Masala'), - catalogueProduct(20, 'aachi', 'Aachi Garam Masala 100g', '100g', '₹22-28', 'Masala'), - catalogueProduct(102, 'aachi', 'Aachi Sambar Powder 50g', '50g', '₹14-16', 'Masala'), - catalogueProduct(51, 'amul', 'Amul Milk 1L', '1L', '₹68-92', 'Dairy'), - catalogueProduct(3, 'amul', 'Amul Butter 500ml', '500ml', '₹80-100', 'Dairy'), - catalogueProduct(37, 'amul', 'Amul Dahi 200 g', '200 g', '₹28-32', 'Dairy'), - catalogueProduct(20, 'amul', 'Amul Ghee 1L', '1L', '₹158-192', 'Dairy'), - catalogueProduct(77, 'amul', 'Amul 150 g', '150 g', '₹37-43', 'Dairy'), - catalogueProduct(9, 'cadbury', 'Cadbury Bournvita 500g', '500g', '₹210-230', 'Beverages'), - catalogueProduct(12, 'cadbury', 'Cadbury Dairy Milk 100g', '100g', '₹77-93', 'Confectionery'), - catalogueProduct(37, 'cadbury', 'Cadbury Oreo 100g', '100g', '₹10-14', 'Biscuits'), - catalogueProduct(15, 'cadbury', 'Cadbury Gems 25g', '25g', '₹22-28', 'Confectionery'), - catalogueProduct(10, 'anil', 'Anil Wheat Flour 1kg', '1kg', '₹251-299', 'Staples'), - catalogueProduct(34, 'anil', 'Anil Idli Dosa Mix 500g', '500g', '₹98-122', 'Staples'), - catalogueProduct(1, 'cavinkare', 'Cavinkare Frooti 200g', '200g', '₹42-48', 'Beverages'), - catalogueProduct(30, 'cavinkare', 'Cavinkare Chik Shampoo 500ml', '500ml', '₹377-463', 'Personal care'), -]; - -/** `(brand, catalogueid)` pairs the demo tenant has already imported. */ -export const DEMO_IMPORTED: Record = { - // Must agree with DEMO_STORE_PRODUCTS — this badges "In your list" in the - // import drawer, and a badge that contradicts the list behind it is worse - // than no badge. - 1135: [ - { brand: 'aachi', catalogueid: 102 }, - { brand: 'amul', catalogueid: 20 }, - { brand: 'cadbury', catalogueid: 1177 }, - { brand: 'nestle', catalogueid: 4412 }, - { brand: 'nestle', catalogueid: 3980 }, - ], - 1136: [{ brand: 'aachi', catalogueid: 89 }], - 1137: [], -}; - -export const DEMO_CATEGORIES: ProductCategory[] = [ - { categoryid: 1, categoryname: 'Grocery & Staples' }, - { categoryid: 2, categoryname: 'Dairy & Chilled' }, - { categoryid: 3, categoryname: 'Snacks & Beverages' }, - { categoryid: 4, categoryname: 'Personal Care' }, - { categoryid: 5, categoryname: 'Household' }, -]; - -export const DEMO_STORE_PRODUCTS: Product[] = [ - { - productid: 7077, - productname: 'Hot Heads 30g', - productsku: 'NESTLE-HOT-30-001', - productimage: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23e74c3c%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%238e2a1f%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3ENESTLE%3C/text%3E%3C/svg%3E', - productbrand: 'nestle', - productunit: '30g', - catalogueid: 4412, - categoryid: 3, - retailprice: 50, - price: 50, - taxpercent: 5, - productcost: 34, - productstock: 47, - productstatus: 'Active', - categoryname: 'Snacks & Beverages', - publishedat: '2026-08-12T09:14:00Z', - }, - { - productid: 7076, - productname: 'Cheerios Breakfast Cereal 100g', - productsku: 'B08X68PTVG', - productimage: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23f0b429%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23b8860b%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3ECHEERIOS%3C/text%3E%3C/svg%3E', - productbrand: 'nestle', - productunit: '100g', - catalogueid: 3980, - categoryid: 1, - retailprice: 120, - price: 120, - taxpercent: 5, - productcost: 88, - productstock: 17, - productstatus: 'Active', - categoryname: 'Grocery & Staples', - publishedat: '2026-08-12T09:14:00Z', - }, - // Priced and released, but the shelf has run to zero. - { - productid: 7081, - productname: 'Cadbury Bournvita 500g', - productsku: 'B06XSB6RB2', - productimage: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%238e5a2b%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%235c3a1a%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EBOURNVITA%3C/text%3E%3C/svg%3E', - productbrand: 'cadbury', - productunit: '500g', - catalogueid: 1177, - categoryid: 3, - retailprice: 100, - price: 100, - taxpercent: 12, - productcost: 71, - productstock: 0, - productstatus: 'Active', - categoryname: 'Snacks & Beverages', - publishedat: '2026-08-09T11:02:00Z', - }, - // Imported and never priced — invisible to every shop until someone acts. - // This is the state the Products tab exists for. - { - productid: 7104, - productname: 'Aachi Sambar Powder 50g', - productsku: 'AACHI-102', - productimage: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23f2c94c%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23c0392b%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EAACHI%3C/text%3E%3C/svg%3E', - productimages: '["data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23f2c94c%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23c0392b%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EAACHI%3C/text%3E%3C/svg%3E","data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23e8d9b5%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%238e5a2b%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3E50g%3C/text%3E%3C/svg%3E"]', - productbrand: 'aachi', - productunit: '50g', - catalogueid: 102, - categoryid: 1, - retailprice: 0, - price: 0, - productcost: 11, - productstock: 0, - productstatus: 'Active', - categoryname: 'Grocery & Staples', - publishedat: null, - }, - { - productid: 7105, - productname: 'Amul Ghee 1L', - productsku: 'AMUL-20', - productimage: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23f7d774%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23c9a227%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EAMUL%3C/text%3E%3C/svg%3E', - productbrand: 'amul', - productunit: '1L', - catalogueid: 20, - categoryid: 2, - retailprice: 0, - price: 0, - productcost: 158, - productstock: 0, - productstatus: 'Active', - categoryname: 'Dairy & Chilled', - publishedat: null, - }, - // Priced but never released — `publishedat` is the only signal that - // separates this from live, and `status` cannot carry it. - { - productid: 7106, - productname: 'Anil Rice Flour 500g', - productsku: 'ANIL-77', - productimage: 'data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%20width%3D%27240%27%20height%3D%27240%27%3E%3Crect%20width%3D%27240%27%20height%3D%27240%27%20fill%3D%27white%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2734%27%20width%3D%27116%27%20height%3D%27172%27%20rx%3D%2712%27%20fill%3D%27%23dfe6e9%27/%3E%3Crect%20x%3D%2762%27%20y%3D%2798%27%20width%3D%27116%27%20height%3D%2734%27%20fill%3D%27%23636e72%27/%3E%3Ctext%20x%3D%27120%27%20y%3D%27121%27%20font-family%3D%27Inter%2Csans-serif%27%20font-size%3D%2715%27%20font-weight%3D%27700%27%20fill%3D%27white%27%20text-anchor%3D%27middle%27%3EANIL%3C/text%3E%3C/svg%3E', - productbrand: 'anil', - productunit: '500g', - categoryid: 1, - retailprice: 42, - price: 42, - taxpercent: 5, - productcost: 30, - productstock: 60, - productstatus: 'Active', - categoryname: 'Grocery & Staples', - publishedat: null, - }, -]; - -/** `gettenantcategories` — synthesised from products in use, not the master. */ -export const DEMO_TENANT_CATEGORIES = [ - { categoryid: 1, categoryname: 'Grocery & Staples' }, - { categoryid: 2, categoryname: 'Dairy & Chilled' }, - { categoryid: 3, categoryname: 'Snacks & Beverages' }, -]; - -/** - * One catalogue row in full, keyed by SKU — what `catalogue/getproduct` returns. - * - * Covers only three of the six store products on purpose. The rest return null, - * which is the common real case rather than an edge one: the tenant's product - * is a snapshot and outlives its source row. - */ -export const DEMO_CATALOGUE_DETAIL: Record = { - 'AACHI-102': { - id: 102, - brand: 'aachi', - product_name: 'Aachi Sambar Powder 50g', - size: '50g', - price_range: '₹14-16', - category: 'Masala', - product_sku: 'AACHI-102', - fssai_license: '10012043000123', - providers: ['BigBasket', 'JioMart'], - highlights: ['Blend of 14 roasted spices', 'No added colour or preservative'], - nutrients: ['Energy 342 kcal / 100g', 'Protein 12.4g'], - }, - 'NESTLE-HOT-30-001': { - id: 4412, - brand: 'nestle', - product_name: 'Hot Heads 30g', - size: '30g', - price_range: '₹45-55', - category: 'Snacks', - product_sku: 'NESTLE-HOT-30-001', - highlights: ['Single-serve pack', 'Contains wheat and milk'], - }, - 'B06XSB6RB2': { - id: 1177, - brand: 'cadbury', - product_name: 'Cadbury Bournvita 500g', - size: '500g', - price_range: '₹95-115', - category: 'Beverages', - product_sku: 'B06XSB6RB2', - fssai_license: '10014051000456', - providers: ['BigBasket', 'Blinkit', 'Zepto'], - highlights: ['With vitamin D and iron', 'Refill pack'], - nutrients: ['Energy 400 kcal / 100g', 'Added sugar 37g'], - }, -}; - -/* ──────────────────────────────────────────────────────────────────────────── - Store Admin — POS, stock and the approval queue - - The three R Mart branches are deliberately in three different states, because - a demo where everything is green teaches nothing about the screen: - - - RS Puram one till, synced, trading normally - - Peelamedu two tills, one of them syncing and one stranded for an hour - - Saravanampatti till offline entirely, with takings queued behind it - ──────────────────────────────────────────────────────────────────────────── */ - -import type { PosSalesSummary, PosTerminalHealth, StockRequest, StockStatementRow } from '@/api/types'; -import type { CustomerInfo } from '@/api/customers'; - -/** Minutes ago, as the ISO string a till would have reported. */ -const ago = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString(); - -export const DEMO_POS_HEALTH: Record = { - 1: [ - { - terminal_id: 'RSP-01', - location_id: '1', - store_name: 'R mart — RS Puram', - app_version: '2.4.1', - status: 'online', - pending_bills: '0', - pending_registrations: '0', - today_bills: '46', - today_amount: '18240', - last_bill_at: ago(3), - reported_at: ago(0), - received_at: ago(0), - printer_reachable: 'true', - storage_free_mb: '2400', - battery_level: '86', - battery_charging: 'true', - drawer_status: 'closed', - }, - { - // The regression case, made visible: still claiming "online", but nothing - // has arrived in six minutes. The old derivation badged this "Synced". - terminal_id: 'RSP-02', - location_id: '1', - store_name: 'R mart — RS Puram', - app_version: '2.4.1', - status: 'online', - pending_bills: '0', - pending_registrations: '0', - today_bills: '31', - today_amount: '11405', - last_bill_at: ago(7), - reported_at: ago(6), - received_at: ago(6), - printer_reachable: 'true', - storage_free_mb: '2100', - }, - { - // Commissioned, heartbeating, never put into service. - terminal_id: 'RSP-03', - location_id: '1', - store_name: 'R mart — RS Puram', - app_version: '2.4.1', - status: 'online', - pending_bills: '0', - pending_registrations: '0', - today_bills: '0', - today_amount: '0', - reported_at: ago(0), - received_at: ago(0), - printer_reachable: 'true', - storage_free_mb: '3100', - }, - ], - 2: [ - { - terminal_id: 'PLM-01', - location_id: '2', - store_name: 'R mart — Peelamedu', - app_version: '2.4.1', - status: 'online', - pending_bills: '3', - pending_registrations: '0', - oldest_pending_at: ago(2), - today_bills: '112', - today_amount: '46310', - last_bill_at: ago(1), - // Reporting a time nine minutes ahead of when we received it — the till's - // clock is wrong, and its bills carry the wrong business date. - reported_at: ago(-9), - received_at: ago(0), - printer_reachable: 'true', - storage_free_mb: '1850', - }, - { - terminal_id: 'PLM-02', - location_id: '2', - store_name: 'R mart — Peelamedu', - app_version: '2.3.9', - status: 'online', - pending_bills: '27', - pending_registrations: '2', - oldest_pending_at: ago(64), - today_bills: '58', - today_amount: '21470', - last_bill_at: ago(64), - reported_at: ago(0), - received_at: ago(0), - printer_reachable: 'false', - storage_free_mb: '140', - battery_level: '14', - battery_charging: 'false', - }, - ], - 3: [ - { - terminal_id: 'SRV-01', - location_id: '3', - store_name: 'R mart — Saravanampatti', - app_version: '2.3.9', - status: 'offline', - pending_bills: '41', - pending_registrations: '0', - oldest_pending_at: ago(196), - today_bills: '39', - today_amount: '15880', - last_bill_at: ago(196), - reported_at: ago(194), - received_at: ago(194), - drawer_status: 'open', - }, - { - // The expired-key stub: terminal, location, status, reason — and nothing - // else. No heartbeat fields at all, which is the whole tell. - terminal_id: 'SRV-02', - location_id: '3', - status: 'offline', - reason: 'presence key expired', - }, - ], -}; - -export const DEMO_POS_SUMMARY: Record = { - 1: { - locationid: 1, - billcount: 46, - itemcount: 214, - grosssales: 18240, - taxcollected: 1640, - discountgiven: 380, - roundoff: 12, - averagebill: 396.5, - bypaymentmode: [ - { paymentmode: 'UPI', amount: 11420, billcount: 31 }, - { paymentmode: 'Cash', amount: 5380, billcount: 12 }, - { paymentmode: 'Card', amount: 1440, billcount: 3 }, - ], - byterminal: [ - { terminalid: 'RSP-01', amount: 18240, billcount: 46 }, - { terminalid: 'RSP-02', amount: 11405, billcount: 31 }, - // Billing, with no health record anywhere. Invisible on the board until - // the sales split is reconciled against it — which is the point. - { terminalid: 'RSP-04', amount: 6820, billcount: 17 }, - ], - byday: [ - { day: '2026-08-17', amount: 15900, billcount: 41 }, - { day: '2026-08-18', amount: 17210, billcount: 44 }, - { day: '2026-08-19', amount: 16480, billcount: 42 }, - { day: '2026-08-20', amount: 19340, billcount: 49 }, - { day: '2026-08-21', amount: 18240, billcount: 46 }, - ], - }, - 2: { - locationid: 2, - billcount: 170, - itemcount: 806, - grosssales: 67780, - taxcollected: 6120, - discountgiven: 1450, - roundoff: 38, - averagebill: 398.7, - bypaymentmode: [ - { paymentmode: 'UPI', amount: 41260, billcount: 104 }, - { paymentmode: 'Cash', amount: 18940, billcount: 52 }, - { paymentmode: 'Card', amount: 7580, billcount: 14 }, - ], - byterminal: [ - { terminalid: 'PLM-01', amount: 46310, billcount: 112 }, - { terminalid: 'PLM-02', amount: 21470, billcount: 58 }, - ], - byday: [ - { day: '2026-08-17', amount: 61200, billcount: 158 }, - { day: '2026-08-18', amount: 64850, billcount: 163 }, - { day: '2026-08-19', amount: 59900, billcount: 151 }, - { day: '2026-08-20', amount: 71340, billcount: 178 }, - { day: '2026-08-21', amount: 67780, billcount: 170 }, - ], - }, - 3: { - locationid: 3, - billcount: 39, - itemcount: 168, - grosssales: 15880, - taxcollected: 1430, - discountgiven: 210, - roundoff: 7, - averagebill: 407.2, - bypaymentmode: [ - { paymentmode: 'Cash', amount: 9640, billcount: 26 }, - { paymentmode: 'UPI', amount: 6240, billcount: 13 }, - ], - byterminal: [{ terminalid: 'SRV-01', amount: 15880, billcount: 39 }], - byday: [ - { day: '2026-08-19', amount: 17420, billcount: 44 }, - { day: '2026-08-20', amount: 16900, billcount: 43 }, - { day: '2026-08-21', amount: 15880, billcount: 39 }, - ], - }, -}; - -export const DEMO_STOCK_REQUESTS: StockRequest[] = [ - { - requestid: 501, - tenantid: 1135, - locationid: 2, - locationname: 'R mart — Peelamedu', - productid: 9001, - productname: 'Aachi Chilli Powder 100gm', - qty: 60, - status: 'Pending', - created: ago(95), - }, - { - requestid: 502, - tenantid: 1135, - locationid: 3, - locationname: 'R mart — Saravanampatti', - productid: 9004, - productname: 'Amul Butter 500g', - qty: 24, - status: 'Pending', - created: ago(240), - }, - { - requestid: 503, - tenantid: 1135, - locationid: 1, - locationname: 'R mart — RS Puram', - productid: 9007, - productname: 'Cadbury Dairy Milk 50g', - qty: 100, - status: 'Pending', - created: ago(410), - }, - { - requestid: 498, - tenantid: 1135, - locationid: 2, - locationname: 'R mart — Peelamedu', - productid: 9002, - productname: 'Aachi Garam Masala 100g', - qty: 40, - status: 'Received', - created: ago(1500), - }, - { - requestid: 497, - tenantid: 1135, - locationid: 1, - locationname: 'R mart — RS Puram', - productid: 9005, - productname: 'Amul Cheese Slices 200g', - qty: 15, - status: 'Rejected', - created: ago(2100), - }, -]; - -/** - * A stock statement per branch. - * - * `debit` is what left the shelf — both channels together, which is why it is - * the figure the fast/slow-mover lists rank on rather than either sales table. - */ -/** - * The customer book, per outlet. - * - * Real shapes rather than lorem: `gettenantcustomers` returns a flat row with - * the delivery address inline, and plenty of records carry a phone and nothing - * else — which is what the empty-cell handling on the page has to survive. - */ -/** - * Merchants created but never approved. - * - * They exist only behind `tenants/search?status=pending` — `getalltenants` - * filters on `approved = 1`, so these rows are invisible everywhere else. - */ -export const DEMO_PENDING_TENANTS: TenantInfo[] = [ - { tenantid: 1141, locationid: 0, tenantname: 'Sri Balaji Stores', locationname: 'Ganapathy', companyname: 'Sri Balaji Traders', primaryemail: 'balaji@stores.in', primarycontact: '9840777111', city: 'Coimbatore', state: 'Tamil Nadu', approved: 0, status: 'Active' }, - { tenantid: 1142, locationid: 0, tenantname: 'Fresh Basket', locationname: 'Ramanathapuram', companyname: 'Fresh Basket Retail', primaryemail: 'hello@freshbasket.in', primarycontact: '9840777222', city: 'Coimbatore', state: 'Tamil Nadu', approved: 0, status: 'Active' }, -]; - -/** The `app_category` master, which the onboarding form should read rather than hardcode. */ -export const DEMO_APP_CATEGORIES = [ - { categoryid: 1, categoryname: 'Grocery & Daily' }, - { categoryid: 2, categoryname: 'Fresh & Produce' }, - { categoryid: 3, categoryname: 'Pharmacy' }, - { categoryid: 4, categoryname: 'Bakery' }, -]; - -export const DEMO_CUSTOMERS: Record = { - 1: [ - { customerid: 5001, firstname: 'Kavitha', lastname: 'R', contactno: '9843012211', email: 'kavitha.r@gmail.com', address: '14, Thadagam Road', suburb: 'RS Puram', city: 'Coimbatore', postcode: '641002', status: 'Active' }, - { customerid: 5002, firstname: 'Manoj', lastname: 'Kumar', contactno: '9894455120', address: '3B, Bharathi Colony', suburb: 'RS Puram', city: 'Coimbatore', status: 'Active' }, - { customerid: 5003, firstname: 'Deepa', contactno: '9787120043', email: 'deepa1990@yahoo.in', suburb: 'Saibaba Colony', city: 'Coimbatore', status: 'Active' }, - { customerid: 5004, firstname: 'Suresh', lastname: 'Babu', contactno: '9600231188', status: 'Active' }, - { customerid: 5005, firstname: 'Fathima', lastname: 'S', contactno: '9445001276', email: 'fathima.s@outlook.com', address: '22, Mettupalayam Road', suburb: 'RS Puram', city: 'Coimbatore', postcode: '641043', status: 'Active' }, - ], - 2: [ - { customerid: 5101, firstname: 'Arun', lastname: 'V', contactno: '9840011223', suburb: 'Peelamedu', city: 'Coimbatore', status: 'Active' }, - { customerid: 5102, firstname: 'Priya', contactno: '9003114455', email: 'priya.v@gmail.com', suburb: 'Peelamedu', city: 'Coimbatore', status: 'Active' }, - ], -}; - -export const DEMO_STOCK_STATEMENT: Record = { - 1: [ - { productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 80, credit: 40, debit: 96, closing: 24, retailprice: 35, locationid: 1 }, - { productid: 9004, productname: 'Amul Butter 500g', opening: 30, credit: 24, debit: 52, closing: 2, retailprice: 265, locationid: 1 }, - { productid: 9007, productname: 'Cadbury Dairy Milk 50g', opening: 120, credit: 0, debit: 120, closing: 0, retailprice: 45, locationid: 1 }, - { productid: 9002, productname: 'Aachi Garam Masala 100g', opening: 60, credit: 20, debit: 31, closing: 49, retailprice: 42, locationid: 1 }, - { productid: 9005, productname: 'Amul Cheese Slices 200g', opening: 25, credit: 10, debit: 8, closing: 27, retailprice: 145, locationid: 1 }, - { productid: 9009, productname: 'Nestlé Munch 32g', opening: 200, credit: 0, debit: 14, closing: 186, retailprice: 20, locationid: 1 }, - ], - 2: [ - { productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 140, credit: 60, debit: 196, closing: 4, retailprice: 35, locationid: 2 }, - { productid: 9004, productname: 'Amul Butter 500g', opening: 60, credit: 48, debit: 91, closing: 17, retailprice: 265, locationid: 2 }, - { productid: 9007, productname: 'Cadbury Dairy Milk 50g', opening: 220, credit: 100, debit: 288, closing: 32, retailprice: 45, locationid: 2 }, - { productid: 9009, productname: 'Nestlé Munch 32g', opening: 300, credit: 0, debit: 41, closing: 259, retailprice: 20, locationid: 2 }, - ], - 3: [ - { productid: 9004, productname: 'Amul Butter 500g', opening: 20, credit: 0, debit: 20, closing: 0, retailprice: 265, locationid: 3 }, - { productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 70, credit: 30, debit: 88, closing: 12, retailprice: 35, locationid: 3 }, - { productid: 9009, productname: 'Nestlé Munch 32g', opening: 150, credit: 0, debit: 9, closing: 141, retailprice: 20, locationid: 3 }, - ], -}; - -/* ──────────────────────────────────────────────────────────────────────────── - Sales rows — app orders, deliveries and counter bills - - The order set mirrors the shape of the live tenant rather than an idealised - one: RS Puram's cancel rate is genuinely 98%, so most of its orders are - cancelled here too. A demo that only shows the happy path hides the exact - thing this page exists to surface. - ──────────────────────────────────────────────────────────────────────────── */ - -import type { DeliveryRow, OrderRow, PosSale } from '@/api/types'; - -/** Hours ago, as the timestamp string the API returns. */ -const hoursAgo = (h: number): string => - new Date(Date.now() - h * 3_600_000).toISOString().slice(0, 19).replace('T', ' '); - -const BRANCH_NAME: Record = { - 1: 'R mart — RS Puram', - 2: 'R mart — Peelamedu', - 3: 'R mart — Saravanampatti', -}; - -const CUSTOMERS = [ - { name: 'Meena Ravi', phone: '9840012233', suburb: 'RS Puram', address: '12, Bharathi Street, RS Puram' }, - { name: 'Karthik S', phone: '9840044556', suburb: 'Peelamedu', address: '7, Nehru Nagar, Peelamedu' }, - { name: 'Divya Prakash', phone: '9840077889', suburb: 'Saravanampatti', address: '22, Sathy Road, Saravanampatti' }, - { name: 'Arun Kumar', phone: '9840099001', suburb: 'Gandhipuram', address: '90, Cross Cut Road, Gandhipuram' }, - { name: 'Lakshmi N', phone: '9840033445', suburb: 'Peelamedu', address: '3, Avinashi Road, Peelamedu' }, -]; - -const RIDERS = ['Suresh M', 'Vignesh R', 'Prakash T']; - -function order( - id: number, - locationid: number, - hours: number, - orderstatus: string, - deliverystatus: string, - amount: number, - items: number, - riderIndex: number | null, -): OrderRow { - const customer = CUSTOMERS[id % CUSTOMERS.length] as (typeof CUSTOMERS)[number]; - const isDone = deliverystatus === 'Delivered'; - const isMoving = deliverystatus === 'Out for Delivery' || isDone; - return { - orderheaderid: 7000 + id, - orderid: `RM${String(24000 + id)}`, - tenantid: 1135, - locationid, - locationname: BRANCH_NAME[locationid], - orderdate: hoursAgo(hours), - orderstatus, - deliverystatus, - itemcount: items, - orderamount: amount, - deliverycharge: 25, - deliverycustomer: customer.name, - deliverycontactno: customer.phone, - deliveryaddress: customer.address, - deliverysuburb: customer.suburb, - pickupcustomer: BRANCH_NAME[locationid], - pickupsuburb: customer.suburb, - deliveryid: deliverystatus ? 5000 + id : 0, - rider: riderIndex === null ? '' : RIDERS[riderIndex], - ridercontactno: riderIndex === null ? '' : '9000011122', - riderkms: isMoving ? (2 + (id % 5)).toFixed(1) : '', - assigntime: riderIndex === null ? '' : hoursAgo(hours - 0.2), - pickuptime: isMoving ? hoursAgo(hours - 0.4) : '', - deliverytime: isDone ? hoursAgo(hours - 0.9) : '', - canceltime: orderstatus === 'Cancelled' ? hoursAgo(hours - 0.3) : '', - }; -} - -export const DEMO_ORDERS: OrderRow[] = [ - // Peelamedu — the branch that actually trades well. - order(1, 2, 0.4, 'Confirmed', 'Out for Delivery', 862, 9, 0), - order(2, 2, 1.1, 'Delivered', 'Delivered', 1240, 14, 1), - order(3, 2, 2.3, 'Delivered', 'Delivered', 415, 5, 0), - order(4, 2, 3.0, 'Preparing', 'Pending', 690, 7, null), - order(5, 2, 4.6, 'Delivered', 'Delivered', 2180, 22, 2), - order(6, 2, 6.2, 'Cancelled', 'Cancelled', 320, 4, null), - order(7, 2, 8.0, 'Delivered', 'Delivered', 975, 11, 1), - // Saravanampatti — steady, a couple in flight. - order(8, 3, 0.8, 'Ready', 'Pending', 540, 6, null), - order(9, 3, 2.0, 'Confirmed', 'Out for Delivery', 1105, 12, 2), - order(10, 3, 5.4, 'Delivered', 'Delivered', 780, 8, 0), - order(11, 3, 9.1, 'Cancelled', 'Cancelled', 260, 3, null), - // RS Puram — the 98% cancel rate, which is the finding worth surfacing. - order(12, 1, 1.5, 'Cancelled', 'Cancelled', 180, 2, null), - order(13, 1, 3.7, 'Cancelled', 'Cancelled', 340, 4, null), - order(14, 1, 5.9, 'Cancelled', 'Cancelled', 95, 1, null), - order(15, 1, 7.2, 'Cancelled', 'Cancelled', 420, 5, null), - order(16, 1, 10.5, 'Delivered', 'Delivered', 1350, 15, 1), -]; - -function bill( - id: number, - locationid: number, - terminalid: string, - cashier: string, - hours: number, - total: number, - items: number, - mode: string, - customer?: string, -): PosSale { - return { - posorderid: 88000 + id, - terminalorderid: `b7f${String(id).padStart(4, '0')}-4a2c-11f0-9cd2-0242ac120002`, - invoicenumber: `${terminalid}/${String(1200 + id)}`, - tenantid: 1135, - locationid, - terminalid, - cashiername: cashier, - customerid: customer ? 4000 + id : 0, - customername: customer ?? '', - billedat: hoursAgo(hours), - businessdate: new Date().toISOString().slice(0, 10), - subtotal: Math.round(total * 0.91), - discount: Math.round(total * 0.02), - taxamount: Math.round(total * 0.09), - roundoff: 0.4, - total, - itemcount: items, - paymentmode: mode, - batchid: `batch-${locationid}-${Math.ceil(hours)}`, - receivedat: hoursAgo(hours - 0.01), - }; -} - -export const DEMO_POS_BILLS: Record = { - 1: [ - bill(1, 1, 'RSP-01', 'Anitha', 0.2, 486, 6, 'UPI', 'Meena Ravi'), - bill(2, 1, 'RSP-01', 'Anitha', 0.9, 1240, 14, 'Cash'), - bill(3, 1, 'RSP-01', 'Anitha', 1.8, 312, 3, 'UPI'), - bill(4, 1, 'RSP-01', 'Ramesh', 3.4, 875, 9, 'Card', 'Arun Kumar'), - bill(5, 1, 'RSP-01', 'Ramesh', 5.1, 204, 2, 'UPI'), - ], - 2: [ - bill(6, 2, 'PLM-01', 'Kavitha', 0.1, 1580, 18, 'UPI', 'Karthik S'), - bill(7, 2, 'PLM-01', 'Kavitha', 0.6, 640, 7, 'Cash'), - bill(8, 2, 'PLM-02', 'Selvam', 1.2, 2340, 26, 'Card', 'Lakshmi N'), - bill(9, 2, 'PLM-01', 'Kavitha', 2.5, 415, 4, 'UPI'), - bill(10, 2, 'PLM-02', 'Selvam', 4.0, 980, 11, 'Cash'), - bill(11, 2, 'PLM-01', 'Kavitha', 6.3, 1120, 13, 'UPI', 'Divya Prakash'), - ], - 3: [ - bill(12, 3, 'SRV-01', 'Bhuvana', 3.3, 720, 8, 'Cash', 'Divya Prakash'), - bill(13, 3, 'SRV-01', 'Bhuvana', 4.8, 395, 4, 'UPI'), - bill(14, 3, 'SRV-01', 'Bhuvana', 7.5, 1260, 15, 'Cash'), - ], -}; - - -/** - * Delivery jobs — a SEPARATE fixture from the orders above, because they are a - * separate read with a different shape. - * - * Two things are deliberately imperfect. `actualkms` exceeds `kms` on a couple - * of jobs, because that gap is the reason the table shows both. And the delivery - * status uses its own ladder (accepted / picked / active), not the order one — - * a demo that reused the order words would make the two maps look redundant. - */ -export const DEMO_DELIVERIES: DeliveryRow[] = [ - { - deliveryid: 5001, orderheaderid: 7001, orderid: 'RM24001', tenantid: 1135, locationid: 2, - deliverydate: hoursAgo(0.7), - locationname: 'R mart — Peelamedu', orderstatus: 'active', - assigntime: hoursAgo(0.6), starttime: hoursAgo(0.55), pickuptime: hoursAgo(0.4), - itemcount: 9, orderamount: 862, deliveryamt: 862, deliverycharges: 45, - pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', pickupcontactno: '9879879877', - deliverycustomer: 'Karthik S', deliverycontactno: '9840044556', - deliveryaddress: '7, Nehru Nagar, Peelamedu', deliverysuburb: 'Peelamedu', - ridername: 'Suresh M', ridercontact: '9000011122', kms: '3.0', actualkms: '3.4', - transitminutes: 18, ordernotes: 'Call on arrival, gate code 4412', - }, - { - deliveryid: 5002, orderheaderid: 7002, orderid: 'RM24002', tenantid: 1135, locationid: 2, - deliverydate: hoursAgo(1.4), - locationname: 'R mart — Peelamedu', orderstatus: 'delivered', - assigntime: hoursAgo(1.3), pickuptime: hoursAgo(1.1), deliverytime: hoursAgo(0.6), - itemcount: 14, orderamount: 1240, deliveryamt: 1240, deliverycharges: 55, - pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', - deliverycustomer: 'Divya Prakash', deliverycontactno: '9840077889', - deliveryaddress: '22, Sathy Road, Saravanampatti', deliverysuburb: 'Saravanampatti', - ridername: 'Vignesh R', ridercontact: '9000011133', kms: '4.0', actualkms: '9.2', - transitminutes: 41, notes: 'Wrong pin dropped, rider re-routed', - }, - { - deliveryid: 5003, orderheaderid: 7005, orderid: 'RM24005', tenantid: 1135, locationid: 2, - deliverydate: hoursAgo(4.9), - locationname: 'R mart — Peelamedu', orderstatus: 'delivered', - assigntime: hoursAgo(4.8), pickuptime: hoursAgo(4.6), deliverytime: hoursAgo(3.9), - itemcount: 22, orderamount: 2180, deliveryamt: 2180, deliverycharges: 60, - pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', - deliverycustomer: 'Meena Ravi', deliverycontactno: '9840012233', - deliveryaddress: '12, Bharathi Street, RS Puram', deliverysuburb: 'RS Puram', - ridername: 'Prakash T', ridercontact: '9000011144', kms: '2.0', actualkms: '2.1', - transitminutes: 14, - }, - { - deliveryid: 5004, orderheaderid: 7004, orderid: 'RM24004', tenantid: 1135, locationid: 2, - deliverydate: hoursAgo(3.1), - locationname: 'R mart — Peelamedu', orderstatus: 'pending', - itemcount: 7, orderamount: 690, deliveryamt: 690, deliverycharges: 0, - pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', - deliverycustomer: 'Lakshmi N', deliverycontactno: '9840033445', - deliveryaddress: '3, Avinashi Road, Peelamedu', deliverysuburb: 'Peelamedu', - kms: '1.8', ordernotes: 'Waiting on packing', - }, - { - deliveryid: 5005, orderheaderid: 7009, orderid: 'RM24009', tenantid: 1135, locationid: 3, - deliverydate: hoursAgo(2.3), - locationname: 'R mart — Saravanampatti', orderstatus: 'picked', - assigntime: hoursAgo(2.2), pickuptime: hoursAgo(1.9), - itemcount: 12, orderamount: 1105, deliveryamt: 1105, deliverycharges: 50, - pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti', - deliverycustomer: 'Lakshmi N', deliverycontactno: '9840033445', - deliveryaddress: '3, Avinashi Road, Peelamedu', deliverysuburb: 'Peelamedu', - ridername: 'Prakash T', ridercontact: '9000011144', kms: '6.0', actualkms: '6.3', - transitminutes: 27, - }, - { - deliveryid: 5006, orderheaderid: 7008, orderid: 'RM24008', tenantid: 1135, locationid: 3, - deliverydate: hoursAgo(1.0), - locationname: 'R mart — Saravanampatti', orderstatus: 'accepted', - assigntime: hoursAgo(0.9), - itemcount: 6, orderamount: 540, deliveryamt: 540, deliverycharges: 40, - pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti', - deliverycustomer: 'Arun Kumar', deliverycontactno: '9840099001', - deliveryaddress: '90, Cross Cut Road, Gandhipuram', deliverysuburb: 'Gandhipuram', - ridername: 'Suresh M', ridercontact: '9000011122', kms: '5.5', - }, - { - deliveryid: 5007, orderheaderid: 7010, orderid: 'RM24010', tenantid: 1135, locationid: 3, - deliverydate: hoursAgo(5.7), - locationname: 'R mart — Saravanampatti', orderstatus: 'delivered', - assigntime: hoursAgo(5.6), pickuptime: hoursAgo(5.4), deliverytime: hoursAgo(4.8), - itemcount: 8, orderamount: 780, deliveryamt: 780, deliverycharges: 45, - pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti', - deliverycustomer: 'Meena Ravi', deliverycontactno: '9840012233', - deliveryaddress: '12, Bharathi Street, RS Puram', deliverysuburb: 'RS Puram', - ridername: 'Suresh M', ridercontact: '9000011122', kms: '2.0', actualkms: '2.4', - transitminutes: 16, - }, - { - deliveryid: 5008, orderheaderid: 7006, orderid: 'RM24006', tenantid: 1135, locationid: 2, - deliverydate: hoursAgo(6.2), - locationname: 'R mart — Peelamedu', orderstatus: 'cancelled', - canceltime: hoursAgo(6.0), - itemcount: 4, orderamount: 320, deliveryamt: 0, deliverycharges: 0, - pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', - deliverycustomer: 'Karthik S', deliverycontactno: '9840044556', - deliveryaddress: '7, Nehru Nagar, Peelamedu', deliverysuburb: 'Peelamedu', - kms: '3.0', notes: 'Customer cancelled before pickup', - }, - { - deliveryid: 5009, orderheaderid: 7012, orderid: 'RM24012', tenantid: 1135, locationid: 1, - deliverydate: hoursAgo(1.7), - locationname: 'R mart — RS Puram', orderstatus: 'skipped', - assigntime: hoursAgo(1.6), - itemcount: 2, orderamount: 180, deliveryamt: 180, deliverycharges: 35, - pickupcustomer: 'R mart — RS Puram', pickupsuburb: 'RS Puram', - deliverycustomer: 'Divya Prakash', deliverycontactno: '9840077889', - deliveryaddress: '22, Sathy Road, Saravanampatti', deliverysuburb: 'Saravanampatti', - ridername: 'Vignesh R', kms: '7.2', notes: 'Nobody at address, returned to store', - }, -]; - -/* ──────────────────────────────────────────────────────────────────────────── - People — back office and till accounts - - Two separate sets on purpose, because they are two separate account systems. - Note the till accounts carry NO password or pin: the backend returns those - only in the answer to a creation, never in a listing, and a fixture that - handed them back in a list would teach the UI a habit the real API refuses. - ──────────────────────────────────────────────────────────────────────────── */ - -import type { PosRole, PosUser, StaffInfo, StaffShift } from '@/api/types'; - -export const DEMO_STAFF: StaffInfo[] = [ - { - userid: 9002, rolename: 'Administrator', roleid: 1, fullname: 'Rmart Operations', - firstname: 'Rmart', lastname: 'Operations', email: 'rmart@gmail.com', contactno: '9876543210', - tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', status: 'Active', - }, - { - userid: 9010, rolename: 'Manager', roleid: 4, fullname: 'Anitha Selvam', - firstname: 'Anitha', lastname: 'Selvam', email: 'anitha@rmart.com', contactno: '9840111222', - tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', status: 'Active', - }, - { - userid: 9011, rolename: 'Manager', roleid: 4, fullname: 'Vijay Anand', - firstname: 'Vijay', lastname: 'Anand', email: 'vijay@rmart.com', contactno: '9840111333', - tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', status: 'Active', - }, - { - userid: 9012, rolename: 'Staff', roleid: 5, fullname: 'Priya Raman', - firstname: 'Priya', lastname: 'Raman', email: 'priya@rmart.com', contactno: '9840111444', - tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', status: 'Active', - }, - { - userid: 9013, rolename: 'Staff', roleid: 5, fullname: 'Mohan Das', - firstname: 'Mohan', lastname: 'Das', email: 'mohan@rmart.com', contactno: '9840111555', - tenantid: 1135, locationid: 3, locationname: 'R mart — Saravanampatti', status: 'Inactive', - }, -]; - -export const DEMO_POS_ROLES: PosRole[] = [ - { - role_id: 7, role: 'supervisor', label: 'Supervisor', - description: - 'Runs the terminal: imports, settings, voids, and creating counter staff. ' + - 'Signs in at a till only — a till account has no Nearle Daily login.', - }, - { - role_id: 8, role: 'cashier', label: 'Cashier', - description: 'Billing only.', - }, -]; - -export const DEMO_SHIFTS: Record = { - 1: [ - { staff_shift_id: 11, tenantid: 1135, locationid: 1, name: 'Morning', start_time: '08:00', end_time: '15:00', weekdays: '1111100', status: 'Active' }, - { staff_shift_id: 12, tenantid: 1135, locationid: 1, name: 'Evening', start_time: '15:00', end_time: '22:00', weekdays: '1111111', status: 'Active' }, - ], - 2: [ - { staff_shift_id: 13, tenantid: 1135, locationid: 2, name: 'Morning', start_time: '07:00', end_time: '15:00', weekdays: '1111111', status: 'Active' }, - { staff_shift_id: 14, tenantid: 1135, locationid: 2, name: 'Late', start_time: '15:00', end_time: '23:00', weekdays: '1111111', status: 'Active' }, - ], - 3: [ - { staff_shift_id: 15, tenantid: 1135, locationid: 3, name: 'Full day', start_time: '08:00', end_time: '22:00', weekdays: '', status: 'Active' }, - ], -}; - -export const DEMO_POS_USERS: Record = { - 1: [ - { user_id: 7001, full_name: 'Anitha Selvam', role: 'supervisor', role_id: 7, contactno: '9840111222', has_password: true, shift_id: 11, shift_name: 'Morning', shift_start: '08:00', shift_end: '15:00', location_id: 1, status: 'Active' }, - { user_id: 7002, full_name: 'Ramesh K', role: 'cashier', role_id: 8, contactno: '9840222111', has_password: true, shift_id: 12, shift_name: 'Evening', shift_start: '15:00', shift_end: '22:00', location_id: 1, status: 'Active' }, - ], - 2: [ - { user_id: 7003, full_name: 'Kavitha M', role: 'supervisor', role_id: 7, contactno: '9840333111', has_password: true, shift_id: 13, shift_name: 'Morning', shift_start: '07:00', shift_end: '15:00', location_id: 2, status: 'Active' }, - { user_id: 7004, full_name: 'Selvam R', role: 'cashier', role_id: 8, contactno: '9840333222', has_password: true, shift_id: 14, shift_name: 'Late', shift_start: '15:00', shift_end: '23:00', location_id: 2, status: 'Active' }, - { user_id: 7005, full_name: 'Deepa N', role: 'cashier', role_id: 8, contactno: '9840333444', has_password: false, location_id: 2, status: 'Inactive' }, - ], - 3: [ - { user_id: 7006, full_name: 'Bhuvana S', role: 'supervisor', role_id: 7, contactno: '9840444111', has_password: true, shift_id: 15, shift_name: 'Full day', shift_start: '08:00', shift_end: '22:00', location_id: 3, status: 'Active' }, - ], -}; diff --git a/src/demo/index.ts b/src/demo/index.ts deleted file mode 100644 index 90c48fc..0000000 --- a/src/demo/index.ts +++ /dev/null @@ -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 | undefined, -): Promise { - 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; -} diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index d9bc873..599b9a1 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -1,19 +1,23 @@ -import { lazy, Suspense, useState, type FormEvent, type ReactNode } from 'react'; +import { useState, type FormEvent, type ReactNode } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; -import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, Sparkles } from 'lucide-react'; +import { + AlertCircle, + ArrowRight, + Eye, + EyeOff, + Loader2, + Lock, + Mail, + ShieldCheck, + Sparkles, +} from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; import { HOME_ROUTE } from '@/auth/roles'; -import { PasswordSetupRequiredError } from '@/auth/session'; -import type { ConsoleRole, SessionUser } from '@/auth/roles'; - -/** - * The demo panel is loaded lazily behind the build-time DEV constant, so in a - * production build this expression is `null`, the import is dead code, and - * neither the panel nor the accounts behind it are emitted at all. - */ -const DemoPanel = import.meta.env.DEV - ? lazy(() => import('@/demo/DemoPanel').then((m) => ({ default: m.DemoPanel }))) - : null; +import { + MIN_PASSWORD_LENGTH, + PasswordSetupRequiredError, + setInitialPassword, +} from '@/auth/session'; /** * Sign-in — KROW's full-bleed auth archetype. @@ -29,16 +33,27 @@ const DemoPanel = import.meta.env.DEV * lands where their account says they belong. */ export function LoginPage() { - const { user, signIn, signInAsDemo } = useAuth(); + const { user, signIn } = useAuth(); const navigate = useNavigate(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const [isRemembered, setIsRemembered] = useState(true); const [error, setError] = useState(null); const [isBusy, setIsBusy] = useState(false); + /** + * The userid `applogin` returned for an account with no password, or null. + * + * Non-null flips this page into its second state. It is deliberately not a + * route: the userid only exists because a sign-in attempt just produced it, + * and a `/set-password` URL that could be opened cold would be a way to set + * any account's password from nothing. + */ + const [setupUserid, setSetupUserid] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + if (user) return ; async function handleSubmit(event: FormEvent) { @@ -49,19 +64,52 @@ export function LoginPage() { const session = await signIn(email, password); navigate(HOME_ROUTE[session.role], { replace: true }); } catch (cause) { - setError( - cause instanceof PasswordSetupRequiredError - ? 'This account has no password set yet. An administrator needs to set one before you can sign in.' - : cause instanceof Error - ? cause.message - : 'Sign-in failed', - ); + // Not a failure — the first step. The account exists and has no password, + // and the backend handed back the userid to set one against. + if (cause instanceof PasswordSetupRequiredError) { + setSetupUserid(cause.userid); + setNewPassword(''); + setConfirmPassword(''); + } else { + setError(cause instanceof Error ? cause.message : 'Sign-in failed'); + } + } finally { + setIsBusy(false); + } + } + + /** + * Set the password, then sign in with it. + * + * Signing in afterwards rather than sending the person back to the form: they + * have just typed the password twice, and `applogin` is the only proof the + * write actually took. + */ + async function handleSetup(event: FormEvent) { + event.preventDefault(); + if (setupUserid === null) return; + setError(null); + + if (newPassword !== confirmPassword) { + setError('Those two passwords do not match.'); + return; + } + + setIsBusy(true); + try { + await setInitialPassword(setupUserid, newPassword); + const session = await signIn(email, newPassword); + navigate(HOME_ROUTE[session.role], { replace: true }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Could not set the password'); } finally { setIsBusy(false); } } const canSubmit = email.trim() !== '' && password !== '' && !isBusy; + const canSetup = + newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy; return (
- setIsPasswordVisible((visible) => !visible)} - onToggleRemember={() => setIsRemembered((remembered) => !remembered)} - onSubmit={handleSubmit} - onDemo={(role, session) => { - signInAsDemo(session); - navigate(HOME_ROUTE[role], { replace: true }); - }} - /> + {setupUserid === null ? ( + setIsPasswordVisible((visible) => !visible)} + onSubmit={handleSubmit} + /> + ) : ( + setIsPasswordVisible((visible) => !visible)} + onSubmit={handleSetup} + onBack={() => { + setSetupUserid(null); + setError(null); + }} + /> + )}
); @@ -238,32 +300,26 @@ interface FormPanelProps { email: string; password: string; isPasswordVisible: boolean; - isRemembered: boolean; error: string | null; isBusy: boolean; canSubmit: boolean; onEmail: (value: string) => void; onPassword: (value: string) => void; onToggleVisible: () => void; - onToggleRemember: () => void; onSubmit: (event: FormEvent) => void; - onDemo: (role: ConsoleRole, session: SessionUser) => void; } function FormPanel({ email, password, isPasswordVisible, - isRemembered, error, isBusy, canSubmit, onEmail, onPassword, onToggleVisible, - onToggleRemember, onSubmit, - onDemo, }: FormPanelProps) { return (
@@ -311,21 +367,7 @@ function FormPanel({ type="button" onClick={onToggleVisible} aria-label={isPasswordVisible ? 'Hide password' : 'Show password'} - style={{ - position: 'absolute', - right: 8, - top: '50%', - transform: 'translateY(-50%)', - width: 28, - height: 28, - display: 'grid', - placeItems: 'center', - borderRadius: 8, - border: 0, - background: 'transparent', - color: 'var(--color-ink-4)', - cursor: 'pointer', - }} + style={eyeButtonStyle} > {isPasswordVisible ? : } @@ -343,104 +385,23 @@ function FormPanel({ /> -
- + {/* Neither "Keep me signed in" nor "Forgot password?" is here any more. + + The checkbox was initialised true, toggled, rendered — and never + read: `handleSubmit` and `signIn(email, password)` never received + it, so ticking or clearing it changed nothing about how long the + session lasted. The link had no handler at all, and there is no + password-reset endpoint in Fiesta to give it one. Both promised + something the system does not do. */} - -
+ - {error ? ( -
- - {error} -
- ) : null} - - +

- {DemoPanel ? ( - - - - ) : null}

); } +/* ──────────────────────────────────────────────────────────────────────────── + 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 ( +
+
+
+
+ + First sign-in +
+

+ Choose a password +

+

+ {email} has no password yet. Set one now and we will sign you straight in. +

+
+ +
+ } + action={ + + } + > + onNewPassword(event.target.value)} + placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} + autoComplete="new-password" + autoFocus + required + aria-invalid={isTooShort} + style={{ ...inputStyle, paddingRight: 40 }} + /> + + + }> + 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)', + }} + /> + + + + {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.'} + + + + + + + + +
+
+ ); +} + +/** A quiet line under the fields — advisory, never an error. */ +function Hint({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/* ──────────────────────────────────────────────────────────────────────────── + Shared form furniture + ──────────────────────────────────────────────────────────────────────────── */ + +function ErrorNote({ message }: { message: string | null }) { + if (!message) return null; + return ( +
+ + {message} +
+ ); +} + +function SubmitButton({ + canSubmit, + isBusy, + label, + busyLabel, +}: { + canSubmit: boolean; + isBusy: boolean; + label: string; + busyLabel: string; +}) { + return ( + + ); +} + +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 ──────────────────────────────────────────────────────────────────────────── */ diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx new file mode 100644 index 0000000..b1b740f --- /dev/null +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -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(null); + const [justImported, setJustImported] = useState>(new Set()); + const [open, setOpen] = useState(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 ( + + {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. */} + + {activeFilters.map((filter) => ( + + ))} + {activeFilters.length > 1 ? ( + + + + {images.slice(0, 8).map((src, position) => ( + + + {/* 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. */} +
+ {/* A RANGE — what other outlets list it at, not a price this store is + bound to. The store sets its own after the import. */} + {product.price_range || '—'} + + {onImport ? ( + isImported ? ( + + Added + + ) : ( + /* "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. */ + + ) + ) : null} +
+ + + ); +} diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx new file mode 100644 index 0000000..bd5ebce --- /dev/null +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -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 ( + : 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. */} + + + + + {images.length > 1 ? ( + + + + + + + {images.length} photos — only the first is imported + + +
+ {images.map((src, index) => ( + + ))} +
+
+ ) : 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. */} + + + + + MARKET PRICE RANGE + + + {product.price_range ?? '—'} + + + {product.size ? : null} + + + + {product.description ? ( + + + DESCRIPTION + + + {product.description} + + + ) : null} + + {product.highlights?.length || product.nutrients?.length ? ( +
+ {product.highlights?.length ? ( + + + HIGHLIGHTS + + + {product.highlights.map((line, index) => ( + + • {line} + + ))} + + + ) : null} + {product.nutrients?.length ? ( + + + NUTRITION + + + {product.nutrients.map((line, index) => ( + + • {line} + + ))} + + + ) : null} +
+ ) : null} + + {facts.length > 0 ? ( + <> + + + {facts.map((fact) => ( + + + {fact.label} + + + {fact.value} + + + ))} + + + ) : null} + + {/* The action, last, because everything above is what the decision is + made on. */} + {isImported ? ( + } + /> + ) : blockedReason ? ( + + ) : onImport ? ( + + + {categoryOptions ? ( + onCategoryChange?.(value)} + placeholder="Leave uncategorised" + description="Your own category, not the catalogue's. Uncategorised beats wrongly categorised." + /> + ) : null} + + Adds this product with no price. It reaches no shop and cannot be sold until you + price and publish it. + + + + {isLoading ? ( + + {Array.from({ length: 6 }, (_, index) => ( +
+ ))} + + ) : null} + + {visible.map((entry) => { + const isOpen = entry.brand === brand; + return ( +
+ + + {/* The categories sit under the brand they belong to, which is + also the only place they can be asked for. */} + {isOpen ? ( + + {isLoadingCategories ? ( + + Loading categories… + + ) : categories.length === 0 ? ( + + No categories for this brand + + ) : ( + <> + + {categories.map((name) => ( + + ))} + + )} + + ) : null} +
+ ); + })} + + {hidden > 0 || isExpanded ? ( + + ) : null} + +
+ + ); +} diff --git a/src/features/nearle-admin/import/ImportDrawer.tsx b/src/features/nearle-admin/import/ImportDrawer.tsx deleted file mode 100644 index 3575884..0000000 --- a/src/features/nearle-admin/import/ImportDrawer.tsx +++ /dev/null @@ -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(() => priceFloor(product.price_range)); - const [productcost, setProductcost] = useState(null); - const [taxpercent, setTaxpercent] = useState(0); - const [quantity, setQuantity] = useState(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 ( - (open ? undefined : onClose())} - className="dialog-panel" - padding={0} - > - (open ? undefined : onClose())} hasDivider /> - - -
- {product.images?.[0] ? ( - - ) : null} -
- - - {product.product_name} - - - {product.brand} · id {product.id} · {product.size ?? '—'} - - - Catalogue range {product.price_range ?? '—'} — a guide, not a price. What you set below - is what the store sells at. - - -
- - - -
- Category * as any} - options={categoryOptions} - value={categoryid} - onChange={setCategoryid} - placeholder="Select category…" - description="The tenant's own category, not the catalogue's." - hasSearch - /> - - Retail price (₹) * as any} value={retailprice} onChange={setRetailprice} min={0} /> - Cost (₹) * as any} value={productcost} onChange={setProductcost} min={0} /> - - -
- - {error ? ( - - - - {errorMessage(error)} - - - ) : null} - - -
- ); -} - -/** - * 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 ( - - {facts.length > 0 ? ( - - {facts.map(([label, value]) => ( - - - {label} - - - {value} - - - ))} - - ) : null} - - {product.highlights?.length ? ( - - {product.highlights.slice(0, 4).map((line) => ( - - • {line} - - ))} - - ) : null} - - ); -} diff --git a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx index 749fbac..77207d5 100644 --- a/src/features/nearle-admin/pages/GlobalCataloguePage.tsx +++ b/src/features/nearle-admin/pages/GlobalCataloguePage.tsx @@ -1,294 +1,171 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Badge } from '@astryxdesign/core/Badge'; -import { Button } from '@astryxdesign/core/Button'; import { Card } from '@astryxdesign/core/Card'; -import { HStack } from '@astryxdesign/core/HStack'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { Selector } from '@astryxdesign/core/Selector'; -import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; import { VStack } from '@astryxdesign/core/VStack'; -import { Check, PackagePlus, Upload } from 'lucide-react'; -import { catalogueKey } from '@/api/catalogue'; -import { productsApi } from '@/api/products'; -import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types'; -import { DataState } from '@/components/DataState'; +import { PackageSearch, Upload } from 'lucide-react'; +import type { TenantInfo } from '@/api/types'; import { PageHeader } from '@/components/PageHeader'; import { SectionHeader } from '@/components/SectionHeader'; -import { - useCatalogueBrands, - useCatalogueProducts, - useImportedRefs, - useTenants, -} from '@/queries/hooks'; -import { queryKeys } from '@/queries/keys'; -import { ImportDrawer } from '../import/ImportDrawer'; +import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser'; +import { useTenantLocations, useTenants } from '@/queries/hooks'; import { SheetImportPanel } from '../import/SheetImportPanel'; -import type { TenantInfo } from '@/api/types'; /** - * The global catalogue browser, and the place both import paths start. + * The platform operator's catalogue. * - * The list loads with NO brand filter — brand, category and keyword are filters - * applied on top of an already-visible list, not a prerequisite to seeing one. - * Products already imported by the selected tenant are badged rather than - * hidden, because re-importing tops up stock and is a legitimate thing to do. + * TWO MODES, AND THEY ASK FOR DIFFERENT THINGS. + * + * - **Browse** is reading. The operator looks at what the FMCG catalogue holds + * — the photographs, the pack sizes, the FSSAI licences, what is stocked + * where — and nothing is written. There is no merchant to choose because + * nothing lands anywhere, so the page is the rail, the search and the grid + * and nothing else. The per-product Add is gone from here: stocking one + * shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸ + * Catalogue. + * + * - **From spreadsheet** is writing, and it cannot work without a target — + * the import endpoint rejects a row with no `tenantid` or `locationid`. So + * the merchant and outlet pickers live in that mode, where the question is + * actually being asked, rather than sitting above a grid that never uses + * them. + * + * The grid, the filters, the cards and the detail drawer are the shared + * `CatalogueBrowser` — the same ones the Store Admin sees, because it is the + * same catalogue. */ export function GlobalCataloguePage() { const [params, setParams] = useSearchParams(); - const queryClient = useQueryClient(); const tenantid = Number(params.get('tenantid') ?? 0) || undefined; const locationid = Number(params.get('locationid') ?? 0) || undefined; - const [brand, setBrand] = useState(''); - const [keyword, setKeyword] = useState(''); - const [selected, setSelected] = useState(null); const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue'); const { data: tenants } = useTenants(); - const { data: brands } = useCatalogueBrands(); - const { - data: products, - isLoading, - error, - } = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 }); - const { data: importedRefs } = useImportedRefs(tenantid); - - /** `brand:catalogueid` — both halves, because a bare id repeats across brands. */ - const importedKeys = useMemo(() => { - const set = new Set(); - for (const ref of importedRefs ?? []) set.add(catalogueKey(ref)); - return set; - }, [importedRefs]); + /** + * The outlets of the chosen merchant, read separately and deliberately. + * + * `getalltenants` is `SELECT * FROM tenants` with no join to + * `tenantlocations` (`tenantRepository.go:79-133`), so its `locationid` is the + * struct's zero value on every row. Seeding the destination from it — which + * this page used to do — meant the id was always 0 and every upload stayed + * blocked behind "pick a merchant" for a merchant already picked. + */ + const locations = useTenantLocations(tenantid); const tenantOptions = useMemo(() => { if (!tenants) return []; - const seen = new Map(); + const seen = new Map(); for (const tenant of tenants as TenantInfo[]) { - if (!seen.has(tenant.tenantid)) { - seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid }); - } + if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname); } - return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name })); + return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name })); }, [tenants]); - const brandOptions = useMemo( - () => [ - { value: '', label: 'All brands' }, - ...(brands ?? []).map((entry) => ({ - value: entry.brand, - label: `${entry.brand} (${entry.product_count})`, + const locationOptions = useMemo( + () => + (locations.data ?? []).map((outlet) => ({ + value: String(outlet.locationid), + label: outlet.locationname || `Outlet ${outlet.locationid}`, })), - ], - [brands], + [locations.data], ); - const importMutation = useMutation({ - mutationFn: (rows: ImportCatalogueProductRequest[]) => productsApi.importFromCatalogue(rows), - onSuccess: async () => { - // Both the imported-refs lookup and the store catalogue go stale together. - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.catalogue.all }), - queryClient.invalidateQueries({ queryKey: queryKeys.products.all }), - ]); - setSelected(null); - }, - }); - function chooseTenant(value: string) { const next = new URLSearchParams(params); next.set('tenantid', value); - const tenant = (tenants as TenantInfo[] | undefined)?.find( - (entry) => String(entry.tenantid) === value, - ); - if (tenant) next.set('locationid', String(tenant.locationid)); + // Cleared rather than carried: the outlets for this merchant have not + // loaded yet, and keeping the previous one's would aim an upload at another + // merchant's shop. + next.delete('locationid'); setParams(next, { replace: true }); } - const hasTarget = Boolean(tenantid && locationid); + function chooseLocation(value: string) { + const next = new URLSearchParams(params); + next.set('locationid', value); + setParams(next, { replace: true }); + } + + /** Most merchants have one outlet; picking it from a list of one teaches nothing. */ + useEffect(() => { + if (!tenantid || locationid) return; + const first = locations.data?.[0]; + if (first) chooseLocation(String(first.locationid)); + // `params` is read through the setter, so it is not a dependency here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantid, locationid, locations.data]); return ( - - ); -} - -/** 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 ( - - {label} - - ); -} diff --git a/src/features/store-admin/ProductsPanel.tsx b/src/features/store-admin/ProductsPanel.tsx index 13bba8e..56273e2 100644 --- a/src/features/store-admin/ProductsPanel.tsx +++ b/src/features/store-admin/ProductsPanel.tsx @@ -10,7 +10,6 @@ import type { Product } from '@/api/types'; import { SectionHeader } from '@/components/SectionHeader'; import { useLocationProducts, useTenantCategories } from '@/queries/hooks'; import { useBranchScope } from './BranchScope'; -import { CatalogueImportDrawer } from './CatalogueImportDrawer'; import { count, money } from './format'; import { ProductDrawer } from './ProductDrawer'; import { PublishDialog } from './PublishDialog'; @@ -40,14 +39,10 @@ import { * the truth on its own. They are shown per row, not as a filter. */ export function ProductsPanel({ - isImporting, - onCloseImport, isUploading, onCloseUpload, }: { - /** Owned by InventoryPage, so its buttons can sit in the tab row. */ - isImporting: boolean; - onCloseImport: () => void; + /** Owned by InventoryPage, so its button can sit in the tab row. */ isUploading: boolean; onCloseUpload: () => void; }) { @@ -132,13 +127,6 @@ export function ProductsPanel({ onClose={onCloseUpload} /> ) : null} - {isImporting && tenantid && branch ? ( - - ) : null} {publishing && tenantid ? ( ('products'); // Owned here so the button can live in the tab row while the drawer it opens // stays inside the panel that uses it. - const [isImporting, setImporting] = useState(false); const [isUploading, setUploading] = useState(false); const requests = useStockRequests( @@ -73,9 +73,14 @@ export function InventoryPage() { title="Inventory" count={selected === null ? `${branches.length} branches` : current?.locationname} description="What you sell, where it is stocked, and what the shops are asking you for." - /* The two product actions live here, not in the tab row, so their - position is fixed — they no longer appear and disappear as the tab - changes, and nothing below shifts sideways when they do. + /* Upload sheet lives here, not in the tab row, so its position is + fixed — it no longer appears and disappears as the tab changes, and + nothing below shifts sideways when it does. + + "Import products" used to sit beside it and is gone: once browsing + the catalogue became a tab, the button was a second door to a room + already on the wall, and it read as a different action than the tab + it opened. The "N awaiting approval" badge that used to sit here is gone: the Stock requests tab already carries the same count, and a number @@ -89,12 +94,6 @@ export function InventoryPage() { isDisabled={!tenantid} onClick={() => setUploading(true)} /> - } - isDisabled={!tenantid} - onClick={() => setImporting(true)} - />
} tabs={ @@ -102,13 +101,19 @@ export function InventoryPage() { giving it a row of its own below. It only appears on that tab — an action for a panel you are not looking at is a misfire waiting to happen. */ - + } isActive={tab === 'products'} onClick={() => setTab('products')} /> + } + isActive={tab === 'catalogue'} + onClick={() => setTab('catalogue')} + /> } @@ -128,11 +133,11 @@ export function InventoryPage() { {tab === 'products' ? ( setImporting(false)} isUploading={isUploading} onCloseUpload={() => setUploading(false)} /> + ) : tab === 'catalogue' ? ( + ) : tab === 'requests' ? ( ) : ( diff --git a/src/features/store-admin/pages/OnboardBranchPage.tsx b/src/features/store-admin/pages/OnboardBranchPage.tsx index 83ea8e2..7235669 100644 --- a/src/features/store-admin/pages/OnboardBranchPage.tsx +++ b/src/features/store-admin/pages/OnboardBranchPage.tsx @@ -55,8 +55,15 @@ const EMPTY: FormState = { contactno: '', address: '', suburb: '', - city: 'Coimbatore', - state: 'Tamil Nadu', + // Empty, not pre-filled. + // + // These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders — + // so a merchant anywhere else was submitted with the wrong city and state + // unless somebody noticed and cleared them. A default that is right most of + // the time is worse than a blank field, because it is only checked when it + // looks wrong. + city: '', + state: '', postcode: '', opentime: isoTime('08:00'), closetime: isoTime('22:00'), diff --git a/src/features/store-admin/pages/ReportsPage.tsx b/src/features/store-admin/pages/ReportsPage.tsx index 6c7b973..cc351f7 100644 --- a/src/features/store-admin/pages/ReportsPage.tsx +++ b/src/features/store-admin/pages/ReportsPage.tsx @@ -110,7 +110,7 @@ export function ReportsPage() { /> } tabs={ - + } diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index 32e16bf..e388e54 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -184,7 +184,7 @@ export function SalesPage() { } tabs={ - + } diff --git a/src/features/store-admin/pages/UsersPage.tsx b/src/features/store-admin/pages/UsersPage.tsx index 7a982c8..77d8709 100644 --- a/src/features/store-admin/pages/UsersPage.tsx +++ b/src/features/store-admin/pages/UsersPage.tsx @@ -125,7 +125,7 @@ export function UsersPage() { /> } tabs={ - + } @@ -426,8 +426,16 @@ function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof T /** Active is quiet, anything else is loud — an inactive account is the exception. */ function StatusChip({ status }: { status: string | undefined }) { - const isActive = (status ?? 'active').toLowerCase().startsWith('active'); - const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)'; + // A missing status is UNKNOWN, not active. Defaulting it to active painted a + // green dot and the word "Active" beside an account whose state the API never + // reported — which is the one case where somebody most needs to look. + const isActive = (status ?? '').toLowerCase().startsWith('active'); + const isUnknown = !status; + const colour = isUnknown + ? 'var(--color-ink-4)' + : isActive + ? 'var(--color-success, #10b981)' + : 'var(--color-ink-4)'; return ( - {isActive ? 'Active' : (status ?? 'Inactive')} + {isUnknown ? 'Unknown' : isActive ? 'Active' : status} ); } diff --git a/src/features/store-admin/posStatus.test.ts b/src/features/store-admin/posStatus.test.ts index 2002362..3fd5ca3 100644 --- a/src/features/store-admin/posStatus.test.ts +++ b/src/features/store-admin/posStatus.test.ts @@ -292,3 +292,19 @@ test('problems come back worst bucket first', () => { assert.equal(list[0]?.bucket, 'now'); assert.equal(list.at(-1)?.bucket, 'look'); }); + +/* + * The regression this file exists to prevent repeating. + * + * `GET /pos/health/location` answers `{location_id, total, online, terminals}`. + * It was typed as a bare array, so the first real sign-in hit `raw.map is not a + * function` — with no error boundary in the app at the time, that rendered as a + * blank page on the Store Admin console. + */ +test('a non-array from the health endpoint reads as no heartbeat, not a crash', () => { + const wrapper = { location_id: '1', total: 2, online: 1, terminals: [till({}), till({})] }; + const summary = summariseBranch(wrapper as unknown as PosTerminalHealth[], NOW); + assert.equal(summary.state, 'no-heartbeat'); + assert.equal(summary.total, 0); + assert.equal(summary.terminals.length, 0); +}); diff --git a/src/features/store-admin/posStatus.ts b/src/features/store-admin/posStatus.ts index 69fb912..53e7252 100644 --- a/src/features/store-admin/posStatus.ts +++ b/src/features/store-admin/posStatus.ts @@ -251,7 +251,10 @@ export function rankOf(state: TerminalState): number { * averaging is exactly how that hour goes unnoticed. */ export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary { - const terminals = raw.map((entry) => readTerminal(entry, now)); + // Defensive because this is a board on the landing page: an unexpected shape + // from the health endpoint should read as "no heartbeat", not take the whole + // console down with it. It already did once — see `PosLocationHealth`. + const terminals = (Array.isArray(raw) ? raw : []).map((entry) => readTerminal(entry, now)); const worst = terminals.reduce( (acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc), diff --git a/src/features/store-user/pages/StoreProductsPage.tsx b/src/features/store-user/pages/StoreProductsPage.tsx index 97e1171..202fe9b 100644 --- a/src/features/store-user/pages/StoreProductsPage.tsx +++ b/src/features/store-user/pages/StoreProductsPage.tsx @@ -156,7 +156,7 @@ export function StoreProductsPage() { ) } tabs={ - + } diff --git a/src/features/store-user/pages/StoreStaffPage.tsx b/src/features/store-user/pages/StoreStaffPage.tsx index 4206cab..c5b7e6e 100644 --- a/src/features/store-user/pages/StoreStaffPage.tsx +++ b/src/features/store-user/pages/StoreStaffPage.tsx @@ -209,15 +209,26 @@ function RoleChip({ label }: { label: string | undefined }) { border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`, }} > - {label || 'cashier'} + {/* Not defaulted to 'cashier' — a till account whose role the API did + not report is unknown, and guessing the lower-privilege one is still a + guess printed in a Role column. */} + {label || '—'} ); } /** A dot and a word, not a pill — the merchant's page reads the same way. */ function StatusChip({ status }: { status: string | undefined }) { - const isActive = (status ?? 'active').toLowerCase().startsWith('active'); - const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)'; + // A missing status is UNKNOWN, not active. Defaulting it to active painted a + // green dot and the word "Active" beside an account whose state the API never + // reported — which is the one case where somebody most needs to look. + const isActive = (status ?? '').toLowerCase().startsWith('active'); + const isUnknown = !status; + const colour = isUnknown + ? 'var(--color-ink-4)' + : isActive + ? 'var(--color-success, #10b981)' + : 'var(--color-ink-4)'; return ( - {isActive ? 'Active' : (status ?? 'Inactive')} + {isUnknown ? 'Unknown' : isActive ? 'Active' : status} ); } diff --git a/src/index.css b/src/index.css index 804b447..55bbb30 100644 --- a/src/index.css +++ b/src/index.css @@ -654,3 +654,528 @@ main { @container drawer (min-width: 900px) { .import-scroll .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } + +/* ──────────────────────────────────────────────────────────────────────────── + Global catalogue — the rail and the product card + ──────────────────────────────────────────────────────────────────────────── + + Modelled on the storefront reference, translated to this console's tokens + rather than its palette: the same proportions and the same rhythm, in + Nearle's purple and the greys every other page uses. Lifting the reference's + orange would have made one page look like a different product. + ──────────────────────────────────────────────────────────────────────────── */ + +/* The search + rail-toggle row, lifted level with the page's tab row. + 42px is the measured distance between the two rows' centres. The bottom + margin then restores the 24px the grid should keep below the tab row — + without it the pull-up drags the whole grid up with it and the first row of + cards slides under the tabs. */ +@media (min-width: 900px) { + .catalogue-controls { + margin-top: -42px; + margin-bottom: 18px; + position: relative; + z-index: 1; + } +} + +.catalogue-layout { + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +@media (min-width: 900px) { + /* The rail is fixed, the grid takes what is left — a rail that grows with the + window steals width from the photographs, which are the content. */ + .catalogue-layout[data-rail='open'] { + grid-template-columns: 216px minmax(0, 1fr); + } +} + +.catalogue-rail { + border: 1px solid var(--color-line); + border-radius: 14px; + background: var(--color-surface); + position: sticky; + top: 72px; + /* The clip lives here, on the element that owns the radius. */ + overflow: hidden; +} + +.rail-scroll { + padding: 6px; + max-height: calc(100dvh - 98px); + overflow-y: auto; + /* A thin, quiet scrollbar. The default is wide enough to read as a second + column beside a 216px rail. */ + scrollbar-width: thin; + scrollbar-color: var(--color-line) transparent; +} + +.rail-scroll::-webkit-scrollbar { + width: 6px; +} + +.rail-scroll::-webkit-scrollbar-thumb { + border-radius: 999px; + background: var(--color-line); +} + +.rail-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.rail-all, +.rail-brand, +.rail-category, +.rail-more { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + border: none; + background: transparent; + border-radius: 9px; + cursor: pointer; + font: inherit; + text-align: left; + color: var(--color-ink-1); +} + +.rail-all { + padding: 9px 10px; + font-size: 13.5px; + font-weight: 700; +} + +.rail-brand { + padding: 8px 10px; + font-size: 13px; + font-weight: 600; +} + +.rail-brand-name { + text-transform: capitalize; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rail-category { + padding: 5px 10px 5px 20px; + font-size: 12.5px; + color: var(--color-ink-3); + justify-content: flex-start; +} + +.rail-all:hover, +.rail-brand:hover, +.rail-category:hover, +.rail-more:hover { + background: var(--color-surface-sunken); +} + +.rail-all[data-active='true'], +.rail-brand[data-active='true'] { + background: color-mix(in oklab, var(--color-brand) 8%, transparent); + color: var(--color-brand); +} + +.rail-category[data-active='true'] { + color: var(--color-brand); + font-weight: 600; +} + +.rail-group[data-open='true'] { + background: color-mix(in oklab, var(--color-brand) 4%, transparent); + border-radius: 10px; +} + +.rail-count { + flex: none; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-4); +} + +.rail-more { + padding: 8px 10px; + margin-top: 2px; + border-top: 1px solid var(--color-line); + border-radius: 0 0 9px 9px; + font-size: 12px; + font-weight: 600; + color: var(--color-brand); + justify-content: center; +} + +.rail-skeleton { + height: 13px; + border-radius: 6px; + background: var(--color-surface-sunken); +} + +/* ── The card ─────────────────────────────────────────────────────────────── */ + +.pcard { + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid var(--color-line); + border-radius: 14px; + background: var(--color-surface); + overflow: hidden; + transition: border-color .18s, box-shadow .18s; +} + +.pcard:hover { + border-color: color-mix(in oklab, var(--color-brand) 38%, transparent); + box-shadow: 0 10px 24px -12px rgb(16 24 40 / .22); +} + +.pcard-media { + position: relative; + /* Flex, not grid, and the reason is not taste: a grid row is sized by its + content unless told otherwise, so an oversized child grows the row past the + container and a percentage max-height then resolves against the GROWN row — + which is why a 240px pack shot stayed 220px tall inside a 150px band. In a + flex container with a definite height, the child's percentage resolves + against that height. */ + display: flex; + align-items: center; + justify-content: center; + width: 100%; + /* A fixed band rather than a square. At `aspect-ratio: 1` the photograph took + more than half the card's height and pushed the name, the size and the + price below the fold of a grid row — the picture identifies the product, + but the text is what is read once it has. */ + height: 150px; + padding: 0; + border: none; + border-bottom: 1px solid var(--color-line); + background: var(--color-surface); + cursor: pointer; + overflow: hidden; +} + +.pcard-media img { + width: 100%; + height: 100%; + object-fit: contain; + /* Catalogue photography is shot on white; without this the photo's own + ground reads as a grey panel inside the card. */ + mix-blend-mode: multiply; + transition: transform .5s ease; +} + +.pcard:hover .pcard-media img { + transform: scale(1.06); +} + +.pcard-noimage { + display: grid; + place-items: center; + color: var(--color-line); +} + +/* The click target, under the controls rather than around them. */ +.pcard-open { + position: absolute; + inset: 0; + z-index: 1; + padding: 0; + border: none; + background: transparent; + cursor: pointer; +} + +/* ── The photo switcher ───────────────────────────────────────────────────── */ + +.pcard-arrow { + position: absolute; + top: 50%; + z-index: 3; + display: grid; + place-items: center; + width: 26px; + height: 26px; + transform: translateY(-50%); + border: 1px solid var(--color-line); + border-radius: 999px; + background: var(--color-surface); + color: var(--color-ink-3); + cursor: pointer; + opacity: 0; + transition: opacity .16s, background .16s, color .16s; + box-shadow: 0 2px 6px rgb(16 24 40 / .12); +} + +.pcard-arrow.is-left { left: 6px; } +.pcard-arrow.is-right { right: 6px; } + +/* Shown on hover, and on keyboard focus — otherwise the only way to reach a + product's other photos would be a mouse. */ +.pcard:hover .pcard-arrow, +.pcard-arrow:focus-visible { + opacity: 1; +} + +.pcard-arrow:hover { + background: var(--color-brand); + border-color: var(--color-brand); + color: #fff; +} + +.pcard-dots { + position: absolute; + bottom: 7px; + left: 50%; + z-index: 3; + display: flex; + gap: 4px; + transform: translateX(-50%); +} + +.pcard-dot { + width: 5px; + height: 5px; + padding: 0; + border: none; + border-radius: 999px; + background: var(--color-line); + cursor: pointer; + transition: width .2s, background .2s; +} + +.pcard-dot[data-active] { + width: 13px; + background: var(--color-brand); +} + +.pcard-owned { + position: absolute; + top: 8px; + padding: 3px 8px; + border-radius: 7px; + font-size: 10px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + pointer-events: none; +} + +.pcard-owned { + right: 8px; + background: #059669; + color: #fff; +} + +.pcard-rail { + position: absolute; + top: 8px; + right: 8px; + display: flex; + flex-direction: column; + gap: 6px; + opacity: 0; + transform: translateX(6px); + transition: opacity .18s, transform .18s; + pointer-events: none; +} + +/* Hidden while the owned badge holds that corner — two things stacked there + is how a badge ends up unreadable. */ +.pcard:hover .pcard-rail { + opacity: 1; + transform: none; +} + +.pcard[data-imported] .pcard-rail { + display: none; +} + +.pcard-railbtn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 999px; + background: var(--color-surface); + border: 1px solid var(--color-line); + color: var(--color-ink-3); + box-shadow: 0 2px 6px rgb(16 24 40 / .1); +} + +.pcard-body { + display: flex; + flex-direction: column; + flex: 1; + gap: 5px; + padding: 11px 12px 12px; + min-width: 0; +} + +.pcard-name { + padding: 0; + border: none; + background: none; + cursor: pointer; + text-align: left; + font: inherit; + /* Medium, not bold. In the reference the product name is the quiet line and + the photograph does the identifying — a grid of two dozen bold names reads + as a list of headlines. */ + font-size: 13px; + font-weight: 500; + line-height: 1.4; + color: var(--color-ink-1); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.pcard-name:hover { + color: var(--color-brand); +} + +.pcard-meta { + margin: 0; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 11.5px; + color: var(--color-ink-4); +} + +.pcard-sku { + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pcard-size { + flex: none; + padding: 1px 6px; + border-radius: 6px; + background: var(--color-surface-sunken); + color: var(--color-ink-3); +} + +.pcard-foot { + margin-top: auto; + padding-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.pcard-price { + white-space: nowrap; + font-size: 14px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--color-ink-1); +} + +.pcard-photos { + flex: none; + font-size: 11px; + color: var(--color-ink-4); +} + +/* ── The action ─────────────────────────────────────────────────────────────── + An outlined pill that fills on hover, which is the reference's treatment and + has no equivalent among Astryx's four Button variants (primary, secondary, + ghost, destructive — all filled or transparent, none outlined). It is drawn + here rather than approximated with the wrong stock variant, in Nearle's + purple rather than the reference's orange: borrowing the palette would have + made one page look like a different product. + ──────────────────────────────────────────────────────────────────────────── */ + +.pcard-action { + display: inline-flex; + align-items: center; + gap: 5px; + flex: none; + padding: 6px 11px; + border: 1px solid var(--color-line); + border-radius: 999px; + background: var(--color-surface); + color: var(--color-ink-2, var(--color-ink-1)); + font: inherit; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: background .15s, border-color .15s, color .15s; +} + +.pcard-action:hover:not(:disabled) { + background: var(--color-brand); + border-color: var(--color-brand); + color: #fff; +} + +.pcard-action:disabled { + cursor: default; + opacity: .55; +} + +.pcard-action.is-added { + border-color: color-mix(in oklab, #059669 40%, transparent); + background: color-mix(in oklab, #059669 8%, transparent); + color: #047857; + cursor: default; +} + +@media (min-width: 560px) { + .catalogue-layout .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (min-width: 1100px) { + .catalogue-layout .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (min-width: 1400px) { + .catalogue-layout .product-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +} + + +/* ──────────────────────────────────────────────────────────────────────────── + Demo-mode flag — development only, dropped from a production build + ──────────────────────────────────────────────────────────────────────────── */ + +.demo-flag { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + padding: 4px 6px 4px 10px; + border: 1px solid #f0b429; + border-radius: 999px; + background: #fef6e4; + color: #8a5a00; + font: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: .02em; + cursor: pointer; +} + +.demo-flag-exit { + padding: 2px 7px; + border-radius: 999px; + background: #8a5a00; + color: #fff; + font-size: 10px; + font-weight: 700; +} + +.demo-flag:hover .demo-flag-exit { + background: #6b4600; +} diff --git a/src/main.tsx b/src/main.tsx index 050f20b..dda3af0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Theme } from '@astryxdesign/core'; import { App } from './App'; import { AuthProvider } from './auth/AuthContext'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { nearleTheme } from './theme/nearle'; import './index.css'; @@ -40,7 +41,13 @@ createRoot(container).render( - + {/* 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. */} + + + diff --git a/src/queries/hooks.ts b/src/queries/hooks.ts index 98685e0..5fcae80 100644 --- a/src/queries/hooks.ts +++ b/src/queries/hooks.ts @@ -99,6 +99,23 @@ export function useCatalogueProducts(query: CatalogueQuery) { }); } +/** + * The categories inside one brand's table. + * + * Brand-scoped because the backend reads them from that brand's own table + * (`catalogueController.go:38-45` refuses without one) — there is no + * catalogue-wide category list to ask for. That is why the rail nests + * categories under a brand rather than listing them at the top level. + */ +export function useCatalogueCategories(brand: string | undefined) { + return useQuery({ + queryKey: queryKeys.catalogue.categories(brand ?? ''), + queryFn: () => catalogueApi.categories(brand as string), + enabled: Boolean(brand), + ...stable, + }); +} + export function useCatalogueBrands() { return useQuery({ queryKey: queryKeys.catalogue.brands(), @@ -118,19 +135,10 @@ export function useImportedRefs(tenantid: number | undefined) { /* ── Products ────────────────────────────────────────────────────────────── */ -export function useProductCategories(tenantid: number | undefined) { - return useQuery({ - queryKey: queryKeys.products.categories(tenantid ?? 0), - queryFn: () => productsApi.categories(tenantid as number), - enabled: typeof tenantid === 'number' && tenantid > 0, - ...stable, - }); -} - /** * The tenant's categories as they are actually used. * - * Prefer this over `useProductCategories`, which reads a master table missing + * Prefer this over `products/getproductcategories`, the master table missing * rows for live categoryids and hardcoded to `moduleid = 2`. */ export function useTenantCategories(tenantid: number | undefined) { @@ -196,21 +204,6 @@ export function useOrderSummary(tenantid: number | undefined) { }); } -/** - * Till health for one outlet. - * - * One request per branch — `locationid` is required and singular on the POS - * endpoints, so there is no tenant-wide call to reach for. - */ -export function usePosHealth(locationid: number | undefined) { - return useQuery({ - queryKey: queryKeys.insights.posHealth(locationid ?? 0), - queryFn: () => insightsApi.posHealth(locationid as number), - enabled: typeof locationid === 'number' && locationid > 0, - ...live, - }); -} - /* ── Store Admin: per-branch fan-out ─────────────────────────────────────── */ /** diff --git a/src/queries/keys.ts b/src/queries/keys.ts index fbec324..ba4355a 100644 --- a/src/queries/keys.ts +++ b/src/queries/keys.ts @@ -25,6 +25,7 @@ export const queryKeys = { products: (params: Record) => [...queryKeys.catalogue.all, 'products', params] as const, brands: () => [...queryKeys.catalogue.all, 'brands'] as const, + categories: (brand: string) => [...queryKeys.catalogue.all, 'categories', brand] as const, importedRefs: (tenantid: number) => [...queryKeys.catalogue.all, 'imported', tenantid] as const, product: (brand: string, sku: string) =>