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:
parent
1b4534a9fc
commit
e697f66e98
7 changed files with 79 additions and 3 deletions
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue