removed files

This commit is contained in:
2026-08-28 11:03:56 +05:30
parent bf4dc4e888
commit 9f64b42f8a
13 changed files with 291 additions and 76 deletions

View File

@@ -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

8
.gitignore vendored
View File

@@ -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

View File

View File

View File

View File

@@ -1,41 +0,0 @@
const fs = require('fs');
const req = (label) => `label={<span>${label} <span style={{ color: 'var(--color-error)' }}>*</span></span>}`;
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");

View File

@@ -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();

View File

@@ -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. */

View File

@@ -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<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
const [open, setOpen] = useState<CatalogueProduct | null>(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}

View File

@@ -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],
);

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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 (
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
<Td>
@@ -773,7 +780,7 @@ function BillsTable({
</Secondary>
</Td>
<Td isMuted>
{clockOf(bill.billedat)}
{clockOf(billedAt ?? bill.billedat)}
</Td>
{showBranch ? (
<Td isMuted>
@@ -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',