WFDASH-WEBAPP/public/pos/sw.js
Dave 4447c4e773 feat(pos): customer picker matches the company name, and stops hiding truncation
The account-sale picker filtered on three customer columns — name,
number, email. `company` on a customer row is a RELATION id, not a
name, so it could never be text-matched; the company name was only ever
resolved afterwards by `expand` for display. Typing "Sibanye" returned
nothing even though 52 people work there.

  - the filter now traverses the relation: `company.company ~ q`.
    Verified live: "Sibanye - Rustenburg" 0 → 52, "Adal" 0 → 2,
    "Alano" 0 → 1, while "Botha" and "Smit" are unchanged at 6 and 4.
  - each row carries `viaCompany`, and name matches sort above
    company-only matches. PB's own sort is by name across the whole
    result set, which would interleave the two bands.
  - /customers returns { items, total, shown } and the till renders a
    count header: "52 matches", or "Showing 100 of 122 — keep typing to
    narrow". The old `slice(0, 8)` showed 8 of 52 people with nothing
    to suggest 44 were missing; 15 companies have more staff than that.

The picker click handler bound to every `div` in the results box, so
adding a count header would have made it selectable and picked a
customer with an empty id. Now scoped to `.cust-row`.

Two bugs the tests caught rather than the code:

  - the display cap was first set to 50 while its own comment claimed
    it sat above the largest company — which has 52 staff. That
    silently re-created the truncation this change removes. Now 100,
    with a test asserting a 52-person company comes back whole.
  - PocketBase has no escape for a literal backslash: `\'` is
    accepted, but one, two or three backslashes all fail to parse and
    500 the request, so typing `\` in the search box errored. No
    escaping survives, so backslashes are stripped before the filter
    (`M20\Nut` searches `M20Nut`) and a backslash-only query returns
    nothing rather than matching every customer.

13 new tests cover the company clause, the 52-staff case, the cap,
ranking order, backslash stripping and the empty-after-strip case.
Verified live against PocketBase across 10 queries — the ranking
guarantee held on every one.

sw.js v33 -> v34: pos-app.js and pos.html are cache-first in the shell.
2026-10-07 15:53:23 +02:00

129 lines
5.8 KiB
JavaScript

// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
// v30 — step 2 of the catalog search work: pos-search.js gains plural
// handling ("nuts" also finds "nut", so "bolts" goes from 0 hits to the 884
// real bolt rows) and glued-token splitting ("M20NUT" finds "M20 Hex Nut").
// Bumped again even though the shell already contained pos-search.js: that
// file is served cache-first, so without a new CACHE name tills would keep
// running the step-1 build of it indefinitely.
// Measured on the live catalog: "nuts m20" 0 → 12, "washers flat" 0 → 14,
// with nothing that used to match ceasing to match.
// v34 — escapePbValue now drops backslashes rather than escaping them:
// PB has no backslash escape in filters (1, 2 or 3 all throw), so a raw one
// 500s the request. v33 already removed the price field from the edit sheet.
// v34 — the account-sale customer picker now matches the COMPANY name as
// well as the person's name/number/email, so typing "Sibanye" returns that
// mine's staff instead of nothing. /api/v1/pos/customers also returns a
// real `total` now: the old `slice(0, 8)` silently showed 8 of 52 people
// at the largest company and read as the whole team, so the till renders
// "showing N of M" and says so when the list is cut. Name matches rank
// above company-only matches so an OR'd filter can't bury the person the
// cashier typed. escapePbValue also escapes backslashes now — a single
// "\" in the search box used to throw and 500 the request.
// v33 — the Edit line sheet is quantity-only. The Unit price field is gone
// on request: the till sells at the catalogue shelf price, and a mis-keyed
// price is expensive and easy to miss. The per-line price override it fed is
// removed with it — the server only ever read `unit_price`, and the
// "price edited" label plus the sale record's `edited` flag are now
// unreachable. Nothing else could set an override, including a cart reload.
// v32 — fix the Edit line sheet, which never actually displayed. #editSheet
// is a sibling of #editBackdrop, but the CSS revealed it with
// `.edit-backdrop.show .edit-sheet`, a descendant selector that can never
// match a sibling — so the sheet sat at opacity:0 while the backdrop went
// opaque. Long-pressing a cart row produced a grey screen with no dialog.
// The sheet now carries its own z-index above the backdrop and its own
// .show rule. This is why manual quantity entry looked missing: the control
// existed and worked, it was just rendering underneath its own backdrop.
// Bumped because pos.html is served cache-first out of the shell.
// v31 — cart quantity is now directly editable: the number in each cart line
// is a real button that opens the existing Edit line sheet (previously only
// reachable by an undiscoverable 600ms long-press), and holding +/- no
// longer pops that sheet open mid-tap. The discount trigger moved out of its
// own 54px footer row to ride inside the Subtotal row, so it costs no
// vertical height while staying visible.
// 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-v34";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./settings.html',
'./slip-layout.html',
'./pos-config.js',
'./pos-search.js',
'./pos-app.js',
'./letterhead-config.js',
'./slip-layout.js',
'./epos-print.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
// Files that must always be fresh (network-first, fallback to cache)
const NETWORK_FIRST = new Set([
'theme.js'
]);
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(APP_SHELL)).then(()=>self.skipWaiting()));
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(()=>self.clients.claim()));
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// Never cache API responses — only the static app shell. Serving a
// stale /catalog or, worse, a cached /checkout would be actively wrong.
if(url.pathname.startsWith('/api/')) return;
// HTML navigations: always network-first so the user gets the latest page
if(e.request.mode === 'navigate'){
e.respondWith(
fetch(e.request).then(res => {
if(res && res.ok) {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => caches.match(e.request).then(r => r || new Response('<h1>Offline</h1>', {status:503, headers:{'Content-Type':'text/html'}})))
);
return;
}
if(e.request.method !== 'GET') return;
// theme.js (and other network-first assets) always go to network
const fname = url.pathname.split('/').pop();
if(NETWORK_FIRST.has(fname)){
e.respondWith(
fetch(e.request).then(res => {
if(res && res.ok){
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => caches.match(e.request).then(r => r || new Response('', {status:504})))
);
return;
}
// Default: cache-first for static assets
e.respondWith(
caches.match(e.request).then(cached => {
const fetchP = fetch(e.request).then(res => {
if(res && res.ok && (e.request.url.startsWith('http'))) {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => cached);
return cached || fetchP;
})
);
});