feat(pos): show the stock quantity on the till tile

The catalog endpoint already sends current_qty and the till already
receives it — pos-app.js just never drew it. The number is shown as it
is, negatives included, and the tile is styled as out of stock when
the quantity is not positive.

The cue is current_qty <= 0, not current_qty <= min_qty: only 48 of
the 4,083 sellable items have a reorder point set, so a min-based rule
would badge every tile and mean nothing.

Nothing is blocked. The cashier can still sell the item and the
quantity still goes into minus.
This commit is contained in:
Dave 2026-11-06 02:18:34 +02:00
parent 1b4534a9fc
commit e697f66e98
7 changed files with 79 additions and 3 deletions

View file

@ -277,11 +277,16 @@ function render(){
const icon = p.category_icon
? `<i class="ic-fa ${escapeAttr(p.category_icon)}" aria-hidden="true"></i>`
: initials(p.category||p.brand||'?');
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
// The catalogue already carries current_qty — the till just never drew
// it. Shown as it is: a negative quantity reads "-2581 in stock",
// because the real number is the signal the operator asked for.
const stockQty = Number(p.current_qty ?? 0);
const oos = stockQty <= 0;
return `<button class="prod${oos ? ' oos' : ''}" data-id="${p.id}" data-od-id="prod-${p.id}">
<div class="ic">${icon}</div>
<div class="body">
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''} · ${stockQty} in stock${inCart?' · '+inCart+' in cart':''}</div>
</div>
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
</button>`;

View file

@ -96,6 +96,11 @@ input{font:inherit;color:inherit}
.prod .sub{font-size:11px;color:var(--muted-fg);margin-top:2px;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.prod .price{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
.prod .price small{font-size:11px;color:var(--muted-fg);font-weight:500;margin-left:2px}
/* Out of stock: the quantity is not positive. The cue is informational —
the item still sells (the till oversells on purpose) — so it only
tints the line. The number itself is never hidden or clamped. */
.prod.oos .sub{color:var(--danger)}
.prod.oos .name{opacity:.72}
/* ---- Cart bottom sheet ---- */
.cart-bar{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--muted);padding:12px 16px calc(12px + env(safe-area-inset-bottom));z-index:20;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow-up);max-width:480px;margin:0 auto;transform:translateY(100%);transition:transform .22s cubic-bezier(.2,.8,.2,1)}
@ -307,6 +312,13 @@ input{font:inherit;color:inherit}
:root[data-theme="dark"] .prod .sub{color:var(--icon-fg)}
:root[data-theme="dark"] .prod .price{color:var(--fg)}
:root[data-theme="dark"] .prod .price small{color:var(--icon-fg)}
/* The dark theme restates `.prod .sub` at a higher specificity than
`.prod.oos .sub`, so the out-of-stock colour needs a dark rule of
its own or it never shows. `#ff6680` is the dark danger colour
this file already uses (.totals.discount-line, .edit-actions .danger);
--danger itself is not rebound for dark and would not clear AA on the
dark canvas. */
:root[data-theme="dark"] .prod.oos .sub{color:#ff6680}
:root[data-theme="dark"] .cart-bar{background:var(--surface);border-top-color:var(--muted);box-shadow:var(--shadow-up)}
:root[data-theme="dark"] .cart-bar .count span{color:var(--icon-fg)}
:root[data-theme="dark"] .cart-bar .total{color:var(--fg)}

View file

@ -60,7 +60,13 @@
// plus blanking the figure when the row is hidden. Also carries the dark
// palette lift.
// v37: pos-app.js accepts a comma as the decimal point (custom item price, cash given).
const CACHE = "pos-v37";
// v38: the catalogue tile shows the stock quantity. current_qty was already
// coming down the wire — the till just never drew it. The number is shown
// as it is (negatives included) and the tile is tinted when the quantity is
// not positive. Nothing is blocked: the item still sells.
// Bumped because pos-app.js and pos.html are both served cache-first out of
// the shell; without a new CACHE name the tills keep the old ones.
const CACHE = "pos-v38";
const APP_SHELL = [
'./',
'./index.html',

View file

@ -264,3 +264,25 @@ describe("POS [hidden] attribute actually hides", () => {
);
});
});
describe("the POS tile shows the stock quantity", () => {
it("renders the quantity on the tile", () => {
expect(POS_APP).toContain("current_qty");
expect(POS_APP).toContain("in stock");
});
it("marks the tile out of stock when the quantity is not positive", () => {
expect(POS_APP).toMatch(/oos/);
});
it("styles the out-of-stock tile in both themes", () => {
// The cue only works if the eye finds it. The dark theme restates
// `.prod .sub` at a higher specificity than `.prod.oos .sub`, so
// without a dark rule of its own the out-of-stock colour is
// overridden and the tile looks like every other one.
expect(POS_HTML).toMatch(/\.prod\.oos \.sub\{[^}]*color:var\(--danger\)/);
expect(POS_HTML).toMatch(
/:root\[data-theme="dark"\] \.prod\.oos \.sub\{[^}]*color:/,
);
});
});

View file

@ -711,3 +711,30 @@ describe("POS never sells at R0", () => {
expect(pb._salesCalls).toHaveLength(0);
});
});
describe("listActiveStockCatalog", () => {
it("returns min_qty alongside current_qty", async () => {
const pb = {
collection: () => ({
getFullList: vi.fn().mockResolvedValue([
{
id: "si1",
sku: "SKU-1",
name: "Widget",
description: "A widget",
unit: "ea",
category: "",
current_qty: 5,
min_qty: 2,
selling_price: 10,
cost_price: 5,
is_active: true,
},
]),
}),
};
const out = await listActiveStockCatalog(pb as never);
expect(out[0].min_qty).toBe(2);
expect(out[0].current_qty).toBe(5);
});
});

View file

@ -157,6 +157,7 @@ export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[
description: string;
unit: string;
current_qty: number;
min_qty: number;
selling_price: number;
cost_price: number;
created: string;
@ -182,6 +183,7 @@ export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[
category: match?.name || "",
category_icon: match?.icon || "",
current_qty: Number(it.current_qty ?? 0),
min_qty: Number(it.min_qty ?? 0),
selling_price: Number(it.selling_price ?? 0),
cost_price: Number(it.cost_price ?? 0),
created: it.created ?? "",

View file

@ -1937,6 +1937,8 @@ export interface PosCatalogItem {
/** FA class string for the category icon (e.g. "fa-solid fa-bolt"), or "" if unset. */
category_icon: string;
current_qty: number;
/** Reorder point (0 = no reorder point set). Carried for the till's benefit. */
min_qty: number;
selling_price: number;
cost_price: number;
/** PB `created` timestamp — the till sorts newest-first off this. */