feat(pos): default catalog to recently added, render in chunks of 50

The till was painting all 373 active stock items at once, which is slow
and buries the stock staff actually reach for. The catalog now comes back
newest-first (PosCatalogItem carries created) and renders 50 at a time,
with a Show-more button that also auto-loads as it scrolls into view.

Header reads 'Recently added' when browsing and 'Results' once a search
or category is applied; changing either resets paging to the first chunk.
Scroll position is preserved across re-renders so adding to the cart no
longer throws the operator back to the top of the list.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-31 02:25:05 +02:00
parent 8e6a6deac9
commit a956b70137
5 changed files with 79 additions and 4 deletions

View file

@ -19,9 +19,17 @@ const state = {
customer: null,
paymentMethod: null,
cashGiven: 0,
letterhead: null
letterhead: null,
// How many rows of the current filter are rendered. The full catalog
// is several hundred items; painting them all makes the till sluggish
// and buries the stock staff actually reach for.
limit: 0
};
// Rows rendered per chunk. The catalog arrives newest-first, so the
// default view is effectively "recently added".
const PAGE = 50;
// ---------------- IndexedDB ----------------
const DB_NAME='pos', DB_VER=1, STORE_PRODUCTS='products', STORE_QUEUE='queue';
let dbPromise = null;
@ -148,6 +156,7 @@ function buildCategories(){
state.activeCat = v === '__uncat' ? '__uncat' : v;
closeCatMenu();
buildCategories();
resetPaging();
render();
});
});
@ -190,6 +199,11 @@ function filteredProducts(){
});
}
/** Call whenever the filter changes, so a new result set starts at the top. */
function resetPaging(){
state.limit = PAGE;
}
function render(){
const list = filteredProducts();
const cat = document.getElementById('catalog');
@ -197,7 +211,19 @@ function render(){
cat.innerHTML = '<div class="empty"><h3>No matches</h3><p>Try a different search or category.</p></div>';
return;
}
cat.innerHTML = list.map(p=>{
if(!state.limit) state.limit = PAGE;
const shown = list.slice(0, state.limit);
const more = list.length - shown.length;
// Keep the scroll position across re-renders (adding to the cart calls
// render()), otherwise the till jumps back to the top on every tap.
const scrollTop = cat.scrollTop;
const browsing = !state.search.trim() && state.activeCat === 'all';
const heading = browsing ? 'Recently added' : 'Results';
cat.innerHTML =
`<div class="meta"><span>${heading}</span><span>${shown.length} of ${list.length}</span></div>` +
shown.map(p=>{
const price = p.selling_price ?? p.cost_price ?? 0;
const inCart = state.cart.get(p.id)?.qty || 0;
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
@ -208,10 +234,29 @@ function render(){
</div>
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
</button>`;
}).join('');
}).join('') +
(more > 0
? `<button class="more" id="showMore">Show ${Math.min(more, PAGE)} more <small>${more} remaining</small></button>`
: '');
cat.querySelectorAll('.prod').forEach(b=>{
b.addEventListener('click', ()=>addToCart(b.dataset.id));
});
cat.scrollTop = scrollTop;
const moreBtn = document.getElementById('showMore');
if(moreBtn){
const loadMore = ()=>{ state.limit += PAGE; render(); };
moreBtn.addEventListener('click', loadMore);
// Auto-load as it scrolls into view; the button stays as the fallback
// for browsers without IntersectionObserver.
if('IntersectionObserver' in window){
const io = new IntersectionObserver(entries=>{
if(entries.some(e=>e.isIntersecting)){ io.disconnect(); loadMore(); }
}, {root: cat, rootMargin: '200px'});
io.observe(moreBtn);
}
}
}
function initials(s){
@ -547,6 +592,7 @@ async function flushQueue(){
document.addEventListener('input', e=>{
if(e.target.id!=='q') return;
state.search = e.target.value;
resetPaging();
render();
});
const topbar = document.getElementById('topbar');

View file

@ -68,6 +68,9 @@ input{font:inherit;color:inherit}
.catalog{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 16px 220px}
.catalog .meta{padding:12px 0 6px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;display:flex;justify-content:space-between}
.catalog .empty{padding:60px 20px;text-align:center;color:#9a9a9a}
.catalog .more{display:flex;flex-direction:column;align-items:center;gap:2px;width:100%;margin:14px 0 8px;padding:14px;border:1px solid var(--muted);border-radius:12px;background:var(--surface-card);color:var(--fg);font-size:14px;font-weight:600;letter-spacing:-.005em}
.catalog .more small{font-size:11px;font-weight:500;color:#717171;letter-spacing:.02em}
.catalog .more:active{background:var(--surface)}
.catalog .empty h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
.catalog .empty p{font-size:13px;line-height:1.5}

View file

@ -31,6 +31,7 @@ describe("listActiveStockCatalog", () => {
current_qty: 12,
selling_price: 99.5,
cost_price: 50,
created: "2026-08-20 10:00:00Z",
expand: { category: { name: "Hardware" } },
},
{
@ -58,6 +59,7 @@ describe("listActiveStockCatalog", () => {
current_qty: 12,
selling_price: 99.5,
cost_price: 50,
created: "2026-08-20 10:00:00Z",
},
{
id: "si2",
@ -69,9 +71,27 @@ describe("listActiveStockCatalog", () => {
current_qty: 0,
selling_price: 10,
cost_price: 5,
created: "",
},
]);
});
it("asks PB for newest-first so the till can default to recently added", async () => {
const getFullList = vi.fn().mockResolvedValue([]);
const pb = {
collection: () => ({
getFullList,
getList: vi.fn(),
getOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}),
};
await listActiveStockCatalog(pb);
expect(getFullList).toHaveBeenCalledWith(
expect.objectContaining({ sort: "-created", filter: "is_active = true" }),
);
});
});
function stubCheckoutPb(opts: {

View file

@ -34,11 +34,14 @@ type StockItemWithCategory = StockItem & {
/**
* Catalog source for the POS: active stock_items only, flattened so
* the frontend doesn't need to know about PB's relation/expand shape.
*
* Sorted newest-first: the till's default view is "recently added", so
* staff see new stock without scrolling a 373-item list.
*/
export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[]> {
const items = await pb.collection("stock_items").getFullList<StockItemWithCategory>({
filter: "is_active = true",
sort: "name",
sort: "-created",
expand: "category",
});
@ -52,6 +55,7 @@ export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[
current_qty: Number(it.current_qty ?? 0),
selling_price: Number(it.selling_price ?? 0),
cost_price: Number(it.cost_price ?? 0),
created: it.created ?? "",
}));
}

View file

@ -1551,6 +1551,8 @@ export interface PosCatalogItem {
current_qty: number;
selling_price: number;
cost_price: number;
/** PB `created` timestamp — the till sorts newest-first off this. */
created: string;
}
/**