removed files
This commit is contained in:
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
117
src/features/store-admin/orderStatus.test.ts
Normal file
117
src/features/store-admin/orderStatus.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user