From 9f64b42f8a534bde9e26c3068da3bd643fc9758f Mon Sep 17 00:00:00 2001 From: abhishek Date: Fri, 28 Aug 2026 11:03:56 +0530 Subject: [PATCH] removed files --- .dockerignore | 7 +- .gitignore | 8 +- _inv.txt | 0 _sync.zip | 0 _their-src.tgz | 0 replace_required.js | 41 ------- scripts/appgap.mjs | 14 ++- src/api/types.ts | 17 ++- src/features/catalogue/CatalogueBrowser.tsx | 67 +++++++++-- src/features/store-admin/CataloguePanel.tsx | 17 ++- src/features/store-admin/orderStatus.test.ts | 117 +++++++++++++++++++ src/features/store-admin/orderStatus.ts | 56 ++++++++- src/features/store-admin/pages/SalesPage.tsx | 23 +++- 13 files changed, 291 insertions(+), 76 deletions(-) delete mode 100644 _inv.txt delete mode 100644 _sync.zip delete mode 100644 _their-src.tgz delete mode 100644 replace_required.js create mode 100644 src/features/store-admin/orderStatus.test.ts diff --git a/.dockerignore b/.dockerignore index 165e1cc..4319b16 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,12 +14,11 @@ node_modules dist -# Scratch. `_to_delete` is spent files awaiting removal and has no business in -# an image. -_to_delete +# Transfer archives. The scratch files these rules were written for — the +# `_to_delete` directory, `_inv.txt`, and two empty archives — are gone from the +# repository; the glob stays so the next one never reaches a build context. *.zip *.tgz -_inv.txt # Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and # the build needs it — but `.env.local` is a developer's private override and diff --git a/.gitignore b/.gitignore index 864bb73..8feb229 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,8 @@ dist .DS_Store .vscode -# Spent files awaiting deletion. Was tracked, which put it in the repo and in -# every Docker build context. -_to_delete - -# Transfer archives. +# Transfer archives. Nothing of the sort belongs in the repository — the two +# that were here (`_sync.zip`, `_their-src.tgz`) had been committed empty and +# stayed for weeks. *.zip *.tgz diff --git a/_inv.txt b/_inv.txt deleted file mode 100644 index e69de29..0000000 diff --git a/_sync.zip b/_sync.zip deleted file mode 100644 index e69de29..0000000 diff --git a/_their-src.tgz b/_their-src.tgz deleted file mode 100644 index e69de29..0000000 diff --git a/replace_required.js b/replace_required.js deleted file mode 100644 index 92f5d3a..0000000 --- a/replace_required.js +++ /dev/null @@ -1,41 +0,0 @@ -const fs = require('fs'); - -const req = (label) => `label={${label} *}`; - -function processFile(file) { - let content = fs.readFileSync(file, 'utf8'); - - // Find all components with isRequired - // We need to match: label="Something" ... isRequired - - // First, find all lines with label="..." - // If a block has isRequired, replace the label and remove isRequired. - - const tags = ['TextInput', 'Selector', 'NumberInput', 'TimeInput']; - - // A simple regex to find JSX tags and their props - const regex = /<(TextInput|Selector|NumberInput|TimeInput)([^>]+)isRequired([^>]*)>/g; - - content = content.replace(regex, (match, tag, before, after) => { - // extract label - const labelMatch = before.match(/label="([^"]+)"/); - if (!labelMatch) { - // maybe label is after isRequired - const labelMatchAfter = after.match(/label="([^"]+)"/); - if (!labelMatchAfter) return match; // fallback - - const newLabel = req(labelMatchAfter[1]); - return `<${tag}${before}${after.replace(labelMatchAfter[0], newLabel)}>`; - } - - const newLabel = req(labelMatch[1]); - return `<${tag}${before.replace(labelMatch[0], newLabel)}${after}>`; - }); - - fs.writeFileSync(file, content); -} - -processFile('src/features/nearle-admin/pages/OnboardTenantPage.tsx'); -processFile('src/features/nearle-admin/pages/OnboardBranchPage.tsx'); - -console.log("Done"); diff --git a/scripts/appgap.mjs b/scripts/appgap.mjs index 7440939..b5beaff 100644 --- a/scripts/appgap.mjs +++ b/scripts/appgap.mjs @@ -133,11 +133,17 @@ if (products.length === 0) { } const listed = new Set(listedRows.map((row) => row.productid)); +/** + * `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`. + * + * This read the long names — the ones `getproductsubcategories` does NOT send — + * so every entry was `undefined → undefined`, the map collapsed to a single + * junk key, and check C below could never match. The footer duly announced + * "(none returned)" for tenant 1147, whose category 2 has six subcategories. + * A diagnostic that is confidently wrong is worse than one that is missing. + */ const realSubs = new Map( - (Array.isArray(subcategories) ? subcategories : []).map((row) => [ - row.subcategoryid, - row.subcategoryname, - ]), + (Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]), ); const inApp = new Set(); diff --git a/src/api/types.ts b/src/api/types.ts index d59bc2a..c77822a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -224,10 +224,23 @@ export interface ProductCategory { categoryname: string; } +/** + * `GET /web/products/getproductsubcategories`. + * + * The wire names are `subcatid` and `subcatname` — ABBREVIATED, unlike + * `ProductCategory` next door, which spells `categoryid`/`categoryname` in + * full. This interface claimed the long forms, so every field read off it was + * `undefined` and TypeScript had no way to know: the rows arrive typed by this + * declaration, not by what the server sent. `scripts/appgap.mjs` built its + * subcategory map from them and got a map of `undefined → undefined`, then + * reported "(none returned)" for a category that has six. + */ export interface ProductSubCategory { - subcategoryid: number; - subcategoryname: string; + subcatid: number; + subcatname: string; categoryid?: number; + image?: string; + status?: string; } /** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */ diff --git a/src/features/catalogue/CatalogueBrowser.tsx b/src/features/catalogue/CatalogueBrowser.tsx index b1b740f..d685fb3 100644 --- a/src/features/catalogue/CatalogueBrowser.tsx +++ b/src/features/catalogue/CatalogueBrowser.tsx @@ -100,7 +100,22 @@ export function CatalogueBrowser({ const [brand, setBrand] = useState(''); const [keyword, setKeyword] = useState(''); const [debounced, setDebounced] = useState(''); - const [importInto, setImportInto] = useState(''); + /** + * `null` means "not chosen yet", which is NOT the same as "no category". + * + * This was `useState('')`, and an empty string went on to `Number('') || 0` + * and imported the product under `categoryid` 0. That reads like a harmless + * default and is not: the customer app queries products by category, and a + * product filed under 0 is returned for NO category value — verified against + * tenant 1147, where `getproductsbysubcategory` answered with the other five + * products at `categoryid=2` and with nothing at 0, 1 or 1001. Two real + * products sat invisible in the app while this console listed them happily, + * because `getlocationproducts` does not filter on category. + * + * Holding "unchosen" apart from "chosen nothing" lets the default below be + * the tenant's own category rather than the value that hides the product. + */ + const [importInto, setImportInto] = useState(null); const [busy, setBusy] = useState(null); const [justImported, setJustImported] = useState>(new Set()); const [open, setOpen] = useState(null); @@ -188,8 +203,31 @@ export function CatalogueBrowser({ }, }); + /** + * The category an import would actually be written under. + * + * Falls back to the tenant's first real category rather than to nothing. + * `categoryOptions` arrives asynchronously, so this is derived at render + * instead of pushed into state by an effect — state seeded from an empty + * list would keep the old default after the categories loaded, and the + * picker would then show one thing while the request sent another. + */ + const defaultCategory = categoryOptions.find((option) => option.value !== '')?.value ?? ''; + const chosenCategory = importInto ?? defaultCategory; + + /** + * True when an import would produce a product the customer app cannot show. + * + * The tenant has no category to file into, so the write would go in under 0. + * Better to say so and refuse than to write a row that looks imported in this + * console and does not exist as far as a shopper is concerned. + */ + const hasNoCategory = !isReadOnly && !onImport && chosenCategory === ''; + async function importDirect(product: CatalogueProduct) { if (!tenantid || !locationid) return; + // Guarded rather than defaulted to 0 — see `hasNoCategory`. + if (chosenCategory === '') return; const key = catalogueKey(product); setBusy(key); try { @@ -198,11 +236,13 @@ export function CatalogueBrowser({ 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, + /* This used to read `Number(importInto) || 0`, defended as + "uncategorised beats wrongly categorised — an unclassified product is + visibly unfinished". It is not visible anywhere: the console lists it + like any other product and only the customer app knows it is gone. + A wrongly filed product is at least findable and fixable; one under + category 0 is returned by no query the app makes. */ + categoryid: Number(chosenCategory), subcategoryid: 0, quantity: 0, stocktype: 'in', @@ -400,9 +440,9 @@ export function CatalogueBrowser({ isBusy={busy === catalogueKey(open)} actionLabel={actionLabel} {...(showCategoryPicker - ? { categoryOptions, categoryid: importInto, onCategoryChange: setImportInto } + ? { categoryOptions, categoryid: chosenCategory, onCategoryChange: setImportInto } : {})} - {...(canImport && !isReadOnly + {...(canImport && !isReadOnly && !hasNoCategory ? { onImport: () => { const product = open; @@ -411,7 +451,16 @@ export function CatalogueBrowser({ }, } : {})} - {...(blockedReason ? { blockedReason } : {})} + {...(blockedReason + ? // The caller's reason wins: "no outlet selected" outranks "no + // category", because you cannot pick one without the other. + { blockedReason } + : hasNoCategory + ? { + blockedReason: + 'This merchant has no product category yet, and a product imported without one is invisible in the customer app. Add a category first.', + } + : {})} onClose={() => setOpen(null)} /> ) : null} diff --git a/src/features/store-admin/CataloguePanel.tsx b/src/features/store-admin/CataloguePanel.tsx index 07d5935..60063a9 100644 --- a/src/features/store-admin/CataloguePanel.tsx +++ b/src/features/store-admin/CataloguePanel.tsx @@ -27,14 +27,23 @@ export function CataloguePanel() { const categories = useTenantCategories(tenantid); + /** + * No "Leave uncategorised" entry, and its absence is the fix. + * + * It sat first in this list, so it was what the picker showed until somebody + * changed it — and it wrote `categoryid` 0, which the customer app cannot + * return under any category it asks for. It was not an opt-out from filing a + * product; it was an opt-out from selling it, offered as the default. + * + * A tenant with no categories now gets an explicit refusal from + * `CatalogueBrowser` rather than a silent 0. + */ const categoryOptions = useMemo( - () => [ - { value: '', label: 'Leave uncategorised' }, - ...(categories.data ?? []).map((entry) => ({ + () => + (categories.data ?? []).map((entry) => ({ value: String(entry.categoryid), label: entry.categoryname, })), - ], [categories.data], ); diff --git a/src/features/store-admin/orderStatus.test.ts b/src/features/store-admin/orderStatus.test.ts new file mode 100644 index 0000000..415fb6e --- /dev/null +++ b/src/features/store-admin/orderStatus.test.ts @@ -0,0 +1,117 @@ +/** + * The counter-bill timestamp repair. + * + * Every fixture below is a real row from location 1185, copied from + * `GET /live/api/v1/web/pos/sales`, because the bug is a property of what the + * tills actually send rather than of anything this codebase does. + * + * These assert IST behaviour and set TZ to match. `billedAtMs` reads the + * viewer's zone deliberately — the console and the shops it serves share one — + * so a test run in UTC would exercise a correction of zero and prove nothing. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { billedAtMs, syncLagMs } from './orderStatus'; + +/* Set after the imports on purpose, and it still takes: `billedAtMs` reads the + zone when it is CALLED, not when this module loads, and assigning TZ calls + tzset(). The import order that ESM enforces is therefore harmless here. */ +process.env['TZ'] = 'Asia/Kolkata'; + +test('the fixtures are being read in the zone they were written for', () => { + // Guard, not ceremony. Every expectation below is IST-specific, and in UTC + // the correction is a no-op — so without this the suite would go green while + // testing nothing. + assert.equal( + new Date('2026-08-27T00:00:00Z').getTimezoneOffset(), + -330, + 'TZ did not take effect; these assertions require Asia/Kolkata', + ); +}); + +/** The common case: 19 of 20 bills. IST wall-clock wearing a `Z`. */ +const SKEWED = { + billedat: '2026-08-27T17:17:39Z', + receivedat: '2026-08-27T11:47:40.921521Z', +}; + +/** `TB0B5` — the one terminal stamping real UTC. Must be left alone. */ +const CORRECT = { + billedat: '2026-08-27T11:15:00Z', + receivedat: '2026-08-27T11:21:11.179745Z', +}; + +/** `T2188` — genuinely offline, caught up 87 minutes later. Skewed as well. */ +const LATE = { + billedat: '2026-08-17T13:46:58Z', + receivedat: '2026-08-17T09:44:06.189763Z', +}; + +test('recovers the instant from a wall-clock stamp labelled Z', () => { + // Rung at 17:17:39 IST, which is 11:47:39 UTC — a second before it landed. + assert.equal(billedAtMs(SKEWED), Date.parse('2026-08-27T11:47:39Z')); +}); + +test('leaves a correctly stamped bill untouched', () => { + assert.equal(billedAtMs(CORRECT), Date.parse('2026-08-27T11:15:00Z')); +}); + +test('renders the hour the sale was actually rung', () => { + const shown = new Date(billedAtMs(SKEWED)!).toLocaleTimeString('en-IN', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + // Not 22:47, which is what formatting the raw stamp produced. + assert.equal(shown, '17:17'); +}); + +test('reports a real lag instead of null', () => { + // Read raw this was −4.05 h, which fell under the threshold and returned + // null, so the row claimed the bill synced on time. + const lag = syncLagMs(LATE); + assert.ok(lag !== null); + assert.ok(lag > 80 * 60_000 && lag < 95 * 60_000, `lag was ${lag}`); +}); + +test('a bill that arrived within the second reports no lag', () => { + // Rung 17:17:39 IST, landed 17:17:40 IST. Under a minute, so nothing to say. + assert.equal(syncLagMs(SKEWED), null); +}); + +test('a correctly stamped bill is still measured, not waved through', () => { + // `TB0B5` took 6 minutes. The timestamps are sound, the lag is real, and the + // repair must not swallow it along with the skew. + const lag = syncLagMs(CORRECT); + assert.ok(lag !== null); + assert.ok(lag > 6 * 60_000 && lag < 7 * 60_000, `lag was ${lag}`); +}); + +test('tolerates a till clock a few minutes fast without correcting it', () => { + const drifting = { + billedat: '2026-08-27T11:20:00Z', + receivedat: '2026-08-27T11:18:00Z', + }; + // Two minutes of drift is drift, not a timezone — a 5.5 h shift here would + // move the bill to the previous morning. + assert.equal(billedAtMs(drifting), Date.parse('2026-08-27T11:20:00Z')); +}); + +test('leaves a stamp the correction cannot rescue alone', () => { + // Days in the future, not hours. Shifting by one zone does not resolve it, + // so the raw value stands rather than being half-corrected into fiction. + const wild = { + billedat: '2026-09-30T10:00:00Z', + receivedat: '2026-08-27T11:00:00Z', + }; + assert.equal(billedAtMs(wild), Date.parse('2026-09-30T10:00:00Z')); +}); + +test('survives the fields being absent or unparseable', () => { + assert.equal(billedAtMs({}), null); + assert.equal(billedAtMs({ billedat: 'not a date' }), null); + // No `receivedat` means no way to test the stamp, so it is taken as sent. + assert.equal(billedAtMs({ billedat: '2026-08-27T11:15:00Z' }), Date.parse('2026-08-27T11:15:00Z')); + assert.equal(syncLagMs({ billedat: '2026-08-27T11:15:00Z' }), null); + assert.equal(syncLagMs({ receivedat: '2026-08-27T11:15:00Z' }), null); +}); diff --git a/src/features/store-admin/orderStatus.ts b/src/features/store-admin/orderStatus.ts index afefd00..956413f 100644 --- a/src/features/store-admin/orderStatus.ts +++ b/src/features/store-admin/orderStatus.ts @@ -122,6 +122,52 @@ export function orderQuantity(row: { quantity?: number; itemcount?: number }): n return row.quantity || row.itemcount || 0; } +/** + * A till clock running a few minutes off is ordinary and not worth correcting. + * Beyond this, a `billedat` in the future is a timezone fault, not drift. + */ +const CLOCK_TOLERANCE_MS = 5 * 60_000; + +/** + * When the sale was actually rung, as a real instant. + * + * `billedat` cannot be trusted as sent. Most terminals stamp it with LOCAL + * wall-clock time and then label it `Z`, so a sale rung at 17:17 IST arrives as + * `2026-08-27T17:17:39Z` — five and a half hours in the future. Measured across + * location 1185: 19 of 20 bills had `billedat` ahead of `receivedat`, which is + * impossible, since a bill cannot be rung after the server received it. One + * terminal (`TB0B5`) sends correct UTC, so the fleet cannot be corrected + * wholesale either. + * + * `receivedat` is stamped by Fiesta and is therefore sound, and it gives a + * test that needs no knowledge of which terminal is which: if `billedat` is + * later than `receivedat` by more than clock drift, the digits are local time + * wearing a `Z`, and reading them back in the viewer's zone recovers the + * instant. The console and the shops it serves are in the same zone, so + * `getTimezoneOffset` is the offset that was dropped. + * + * The correction is applied only when it actually resolves the impossibility; + * a bill that is still in the future afterwards is something else, and guessing + * further would be inventing data. Everything else passes through untouched — + * which means this quietly stops correcting the day the POS team stamps UTC. + */ +export function billedAtMs(bill: { billedat?: string; receivedat?: string }): number | null { + if (!bill.billedat) return null; + const billed = new Date(bill.billedat).getTime(); + if (Number.isNaN(billed)) return null; + + if (!bill.receivedat) return billed; + const received = new Date(bill.receivedat).getTime(); + if (Number.isNaN(received)) return billed; + + if (billed <= received + CLOCK_TOLERANCE_MS) return billed; + + // `getTimezoneOffset` is (UTC − local) in minutes: −330 for IST. Adding it + // turns wall-clock-read-as-UTC back into the instant it stood for. + const corrected = billed + new Date(billed).getTimezoneOffset() * 60_000; + return corrected <= received + CLOCK_TOLERANCE_MS ? corrected : billed; +} + /** * How far behind a counter bill was when it reached us. * @@ -129,15 +175,21 @@ export function orderQuantity(row: { quantity?: number; itemcount?: number }): n * gap means the till was offline and caught up later, and it is worth showing * on the row: a day's takings that all arrived at 6pm did not happen at 6pm. * + * Measured against `billedAtMs`, not the raw stamp. Read raw, every skewed bill + * produced a NEGATIVE lag — which fell under the threshold and returned null, + * so this reported "synced on time" for all twenty bills at location 1185 and + * would have gone on doing so through a real outage. Silent wrong is worse + * than blank. + * * Returns null when the gap is under a minute (normal) or when either stamp is * missing — a bill with no `receivedat` predates the field, and calling that * "0 seconds late" would be a claim the data does not support. */ export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null { if (!bill.billedat || !bill.receivedat) return null; - const billed = new Date(bill.billedat).getTime(); + const billed = billedAtMs(bill); const received = new Date(bill.receivedat).getTime(); - if (Number.isNaN(billed) || Number.isNaN(received)) return null; + if (billed === null || Number.isNaN(received)) return null; const lag = received - billed; return lag >= 60_000 ? lag : null; } diff --git a/src/features/store-admin/pages/SalesPage.tsx b/src/features/store-admin/pages/SalesPage.tsx index 64dc84e..5aef6cd 100644 --- a/src/features/store-admin/pages/SalesPage.tsx +++ b/src/features/store-admin/pages/SalesPage.tsx @@ -27,6 +27,7 @@ import { orderQuantity, orderValue, statusColor, + billedAtMs, syncLagMs, type StatusKey, } from '../orderStatus'; @@ -114,7 +115,11 @@ export function SalesPage() { .toLowerCase() .includes(term), ) - .sort((a, b) => (b.bill.billedat ?? '').localeCompare(a.bill.billedat ?? '')); + /* Sorted on the recovered instant, not the raw stamp. A string compare + put the one terminal that sends correct UTC five and a half hours + adrift of the rest, so newest-first was newest-first only within a + terminal. */ + .sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0)); }, [billPages, branchIds, branches, keyword]); /** Counts for the tab strip, from the unfiltered set. */ @@ -753,6 +758,8 @@ function BillsTable({ restarting its series would collide. */} {rows.map(({ bill, branch }) => { const lag = syncLagMs(bill); + // The recovered instant, not the stamp — see `billedAtMs`. + const billedAt = billedAtMs(bill); return ( @@ -773,7 +780,7 @@ function BillsTable({ - {clockOf(bill.billedat)} + {clockOf(billedAt ?? bill.billedat)} {showBranch ? ( @@ -824,10 +831,16 @@ function stamp(value: string | undefined): string { return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`; } -function clockOf(value: string | undefined): string { - if (!value) return '—'; +/** + * Takes an epoch too, so a bill can pass the instant `billedAtMs` recovered + * rather than the stamp it arrived with. Formatting the raw `billedat` shifted + * an already-wrong time by another +05:30 and put a 17:17 sale on the row at + * 22:47 — two bugs compounding into a plausible-looking number. + */ +function clockOf(value: string | number | undefined): string { + if (value === undefined || value === '') return '—'; const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return value; + if (Number.isNaN(parsed.getTime())) return String(value); return parsed.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit',