diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index e4907d4..ead06ef 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -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 = '

No matches

Try a different search or category.

'; 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 = + `
${heading}${shown.length} of ${list.length}
` + + shown.map(p=>{ const price = p.selling_price ?? p.cost_price ?? 0; const inCart = state.cart.get(p.id)?.qty || 0; return ``; - }).join(''); + }).join('') + + (more > 0 + ? `` + : ''); + 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'); diff --git a/public/pos/pos.html b/public/pos/pos.html index 6e0e882..a8b09a9 100644 --- a/public/pos/pos.html +++ b/public/pos/pos.html @@ -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} diff --git a/src/lib/server/pos-service.test.ts b/src/lib/server/pos-service.test.ts index 1491f84..24b5e29 100644 --- a/src/lib/server/pos-service.test.ts +++ b/src/lib/server/pos-service.test.ts @@ -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: { diff --git a/src/lib/server/pos-service.ts b/src/lib/server/pos-service.ts index aae13fd..37889b7 100644 --- a/src/lib/server/pos-service.ts +++ b/src/lib/server/pos-service.ts @@ -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 { const items = await pb.collection("stock_items").getFullList({ filter: "is_active = true", - sort: "name", + sort: "-created", expand: "category", }); @@ -52,6 +55,7 @@ export async function listActiveStockCatalog(pb: PosPb): Promise