Commit graph

58 commits

Author SHA1 Message Date
d98c263f45 feat(scan): standalone delivery-note scanner PWA at /scan
Scan the QR printed on a delivery note, see what it is, and act on it:
reprint, photograph the signed copy, or walk it toward invoicing. It is its
own route group outside `(app)` so it gets a clean full-bleed layout and its
own manifest — "add to home screen" should give the operator a scanner, not
the CRM. Mirrors the pattern `(counter)` already uses.

Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is
Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug
#254573) and Firefox does not ship it. A scanner that only used it would
simply not work on an iPhone. So native where it exists, jsQR everywhere
else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the
delivery-note QR is always a QR code. Both run against the same canvas frame
so the caller never branches. Also requires HTTPS, which the page says
plainly when it isn't.

The QR carries the human number, so the first call is a new resolve endpoint
mapping number -> PB id. Everything after that speaks ids, which is what every
existing route already takes. The number is shape-validated before any network
call, so a supplier's packing label can't fire a lookup.

STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and
customer-po refuses a note that isn't sent/loaded — so rather than offering a
button that 500s, each action only appears when the server would accept it, and
the card shows which step comes next. It routes draft -> loaded -> po_received
-> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent
means "create a quote and email it" and a warehouse scan must never trigger
that silently.

The signed-copy upload deliberately uses the upload-only endpoint, not
/api/v1/deliveries/mark-signed-copy — that one flips the note to `completed`
in the same write, which would jump a note straight past invoicing.

Manual entry is always available, so the page works on a device with no camera
or a browser that won't scan.

Tests decode the REAL delivery-note QR through the production fallback path at
print size, and assert the shape check rejects a supplier QR, a random string
and an over-long number.
2026-10-04 13:15:56 +02:00
a499265b26 feat(pos): raise a system delivery note on an account sale
An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.

An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.

`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.

Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.

Also in here:
  - DN number collision retry. The generator reads the highest existing number
    and adds one, which is not atomic, while PB holds a unique index on it. The
    POS adds a second concurrent writer, so two creates could collide and 500
    on a benign condition.
  - GET /api/v1/pos/sales now expands customer, customer.company and
    delivery_note so history.html can rebuild the DN card on a reprint. The
    nested customer.company matters — customer.company is itself a relation,
    so without it the card printed a raw PB id where the name belongs.
  - "Print slip" is hidden for account sales; that tender produces a delivery
    note, not a slip.

New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.

Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
2026-10-04 13:15:29 +02:00
cbf45048f2 fix(pos): point the cash-up printer errors at the right screen
There is exactly one printer setting (pos.printerSettings, written only
by receipt.html's "Save printer settings"), and its form lives on the
slip screen behind the "Printer: …" row — not on the Settings page.
Both cash-up error paths told the operator to open "Printer settings",
where they'd find nothing. Name the actual location instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:46:57 +02:00
bcd9b88dd4 fix(pos): revive the dead "Print cash-up" button, add 20c/10c denominations
The button has never worked. cashup.html wires it with an inline
onclick="printCashup()", but the page's whole script is IIFE-wrapped, so
printCashup was never a global — the click threw "ReferenceError:
printCashup is not defined" into the console and the button silently did
nothing. No alert, no dialog, no feedback.

Dead since 46d5d54 swapped the button from onclick="window.print()" (a
real global, so it worked) to the ePOS-Print path. receipt.html hit this
exact bug earlier and fixed it by exporting its handlers to window — with
a comment noting the buttons "all silently do nothing". cashup.html never
got the same line. It does now.

On the network transport the cash-up slip already mirrored the sale slip
(resolve 'cashup' blocks -> letterheadLogoRaster at 384 -> printViaNetwork),
so the export is the whole fix for a till on a network printer.

Also here:

- 20c and 10c added to the drawer count, with the total rounded to whole
  cents. Neither is exact in binary floating point, so 3x10c would
  otherwise have summed to 0.30000000000000004 and printed a variance
  that reads as a rounding bug on the slip.
- The button restored its label with textContent, which dropped its
  printer <svg> permanently on first use. Restores innerHTML instead.
- The Bluetooth branch quietly called window.print() (OS dialog, not the
  thermal printer). getSettings() DEFAULTS to bluetooth, so any till with
  cleared settings would land there and look dead all over again. It now
  fails loudly, matching EposPrint.printTest()'s stance. No ESC/POS
  cash-up renderer yet — only the sale slip speaks Bluetooth.

pos-app.js had the same defect in four more places: the edit-line modal's
Save / Cancel / Remove and the customer "Remove" chip are all inline
onclick handlers declared inside its IIFE, and only openEditLine was
exported — so the modal could be opened but not dismissed or committed.
Confirmed by parsing the file (one top-level statement, no top-level
function declarations), fixed with four additive export lines.

src/lib/pos-inline-handlers.test.ts guards the class across every POS
page: any identifier called from an inline on* attribute must be a
builtin, a namespaced call, or exported on window. It was red on exactly
these bugs and is green now.

SW cache bumped to pos-v26 so the tills pick the files up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:30:11 +02:00
6bedf27617 fix(pos): print the letterhead's own logo, and match delivery note to the sale slip
Logo — three separate faults, all of which had to be fixed for the
letterhead logo to print at all:

1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it
   printed the app's bundled mark (the solid black W block) and never
   looked at the letterhead at all.
2. The paths that DID use the letterhead (printTest, cashup) passed
   PocketBase's fully-qualified cross-origin URL into imageToRaster(),
   which draws to a <canvas> — getImageData() taints and throws on a
   cross-origin image, so the .catch(() => null) silently dropped the
   logo. pos-letterhead-service now returns a same-origin
   /api/v1/files/... URL (per AGENTS.md's file-URL rule).
3. letterhead_settings wasn't whitelisted in that file proxy, so the
   same-origin URL would have 403'd.

Verified against the live WESTSCOTT letterhead: its SVG rasterizes to
384x137 at ~15% ink coverage (line art), no tainted canvas.

Also fixes the company name never printing: every builder read
`letterhead.name`, but the POS letterhead record exposes
`display_name` — so the name line silently rendered empty on every
slip, on screen and on paper.

Delivery note now matches the sale slip, per request: gains the logo
and business_info blocks and the same 2x-size title, instead of being
a stripped-down variant. This also reconciles the transport
divergence flagged earlier (Bluetooth baked business info into its
header; the network builder omitted it entirely) — it is now one
reorderable business_info block on both.

resolveBlocks() now slots a newly-shipped block in at its intended
position rather than appending it to the end. Without this, any
letterhead with an already-saved layout would have printed the new
logo/business_info blocks BELOW the signature line.
2026-09-06 23:03:24 +02:00
c8b7e1f51b feat(rep): visit check-in PWA — phone GPS, offline outbox
The rep app: open it on site, it finds who's around you, tick who you
saw, done. Third instance of the per-route-group PWA pattern already
running here (/m boss phone, /counter), so it reuses MobileAuthGate and
gets its own manifest + service worker.

Phone GPS is the primary position source, not the vehicle tracker. The
tracker knows where the bakkie is; the phone knows where the rep is,
and those diverge by a few hundred metres once the rep walks into the
plant. GPS also needs no network, which matters because mine shafts
have poor signal. The tracker is the secondary path
(GET /api/v1/visits/position) for when location is denied or
unavailable. The independently-collected vehicle trail corroborates
check-ins afterwards, so honesty doesn't depend on the phone.

Matching runs on the phone, not the server. The app caches a compact
customer snapshot and runs rankNearbyCustomers locally, so there's one
code path that behaves identically online and off — rather than a
server path plus an offline fallback that only gets exercised in the
places it's hardest to debug.

- (rep)/rep/            layout, page, check-in view, outbox
- visits-service.ts     getCustomerSnapshot, recordVisits
- POST /api/v1/visits           record a check-in
- GET  /api/v1/visits/customers snapshot for the offline cache
- GET  /api/v1/visits/position  tracker fallback
- public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch

These routes authenticate with the browser session, not AGENT_API_KEY —
the caller is a logged-in rep. The rep id comes from the validated
session rather than the request body, so a client can't log a visit as
someone else.

The outbox uses localStorage rather than IndexedDB deliberately: the
payloads are tiny and localStorage is synchronous, which matters when
the rep hits Save and immediately pockets the phone. Entries are only
removed once the server acknowledges them, so a failed flush retries
instead of dropping a visit — duplicates are preferred over loss, and
surfaced rather than merged. Permanently-rejected entries (4xx other
than 401/408/429) are dropped so one bad row can't wedge the queue.

recordVisits writes each visit independently: a stale customer id
shouldn't cost the other four visits in a queued batch.

BLOCKED until one PB admin change: rep_visits.position_source does not
yet allow "phone" — verified against the live instance, PB returns
400 "Invalid value phone". Add it to the select. Separately,
accuracy_m is silently accepted and discarded (PB ignores unknown
fields), so GPS accuracy is thrown away until that number field is
added; not blocking.

47 new tests. Verified on :4001: /rep serves 200 with the right
metadata, manifest-rep.json and sw-rep.js are served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:09:27 +02:00
fc361edfc8 fix(pos): reset stateful printer attributes per-block instead of relying on order
Root cause of the oversized/wrapping text on the printed slip: ePOS-
Print XML's <text> attributes (width, height, em) are stateful on the
printer — they persist across subsequent <text> tags until explicitly
changed back, not per-tag like CSS. The sale slip's header_title block
sets width="2" height="2" for the big title and nothing after it ever
reset back to width="1" height="1", so the entire rest of the receipt
(business info, items, totals, payment, footer) inherited double-size
text — exactly the wrapping/misalignment seen in the photo. Same
issue existed in the delivery note (header's height="2" leaking into
every line after it) and cash-up (GRAND TOTAL's em="true" leaking
into CASH RECONCILIATION's non-bold lines).

Now every <text> tag in all three XML builders explicitly declares
width/height/em rather than relying on inheritance from whatever
tag ran before it. This isn't just a display fix — since blocks are
now user-reorderable/toggleable, no block can safely assume what
state a neighboring block left behind; every block must be correct
in isolation regardless of order.

Same class of bug existed on the Bluetooth ESC/POS side: ESC/POS's
alignment command is equally persistent, and the sale slip's
header_title centered the title but relied on the next block
(business_info) to reset it back to left — broken the moment
business_info is disabled or reordered. Fixed the same way: each
block resets what it changed before returning.
2026-09-04 15:58:08 +02:00
ec711760a3 fix(pos): slip layout preview collapsed to a single squished line
Two compounding issues, both pre-existing (this exact <span>-joining
pattern and CSS was carried over unchanged from the old settings.html
preview I moved in the previous commit):

1. Preview lines are joined as adjacent <span> elements with no
   separator, so without an explicit block display they render as
   one continuous inline line instead of stacked rows.
2. .receipt-preview sits directly in body's flex-column layout and
   has overflow-x:auto — per the CSS Flexbox spec, an overflow-bearing
   flex item's automatic minimum size becomes 0 instead of its content
   size, so the flex column was free to squeeze it down to a sliver
   regardless of its 19 lines of content.

Fixes: span{display:block} for the per-line stacking, flex-shrink:0
so the flex column can't shrink it below its content height.
2026-09-04 14:07:39 +02:00
978456a807 feat(pos): add slip-layout.html block editor, move preview out of settings.html 2026-09-04 13:42:01 +02:00
238642c949 feat(pos): delivery note pricing checkbox defaults from the letterhead's saved setting 2026-09-04 13:35:49 +02:00
f07d70b976 refactor(pos): make buildEscPos/buildEscPosDelivery (Bluetooth) block-driven
Also fixes a live bug found while refactoring: buildEscPos returned a
Uint8Array, but its caller in printThermalBluetooth() ran the result
through TextEncoder.encode() (which expects a string) before writing
to the Bluetooth characteristic — encoding a Uint8Array's default
toString() ("27,64,27,...") instead of the actual bytes. Regular
(non-account) sale slips printed over Bluetooth have been sending
garbled data. Both builders now return plain strings, matching
buildEscPosDelivery's already-correct pattern.

Also preserves a pre-existing divergence rather than silently
resolving it: Bluetooth's delivery note prints letterhead business
info (name/address/phone/VAT), the network XML version never has.
Kept as-is per transport (folded into Bluetooth's header_title block)
rather than unilaterally adding or removing content — worth an
explicit decision later on which behavior is correct.
2026-09-04 13:35:19 +02:00
d47394723b refactor(pos): make buildEposCashupXml block-driven, fix broken logo image tag
Also fixes a logo-ordering bug this surfaced: slip-layout.js's sale
and cashup defaults put header_title before logo, which (once a real
logo renders instead of the always-empty placeholder used in earlier
regression tests) reversed the original image-then-title print order.
Reordered both to logo-first and added the missing logo block to
cashup's default catalog, since cashup's original code already
attempted (brokenly) to print one.
2026-09-04 13:31:13 +02:00
317b79ed29 refactor(pos): make buildEposDeliveryXml block-driven 2026-09-04 13:28:45 +02:00
81197ad641 refactor(pos): make buildEposXml block-driven 2026-09-04 13:27:13 +02:00
c56211d35e chore(pos): wire slip-layout.js into receipt/cashup/settings, bump SW cache 2026-09-04 13:25:31 +02:00
8e879d1447 feat(pos): add shared slip-layout block catalog and resolver 2026-09-04 13:24:40 +02:00
7d2132e000 fix(pos): fix uncaught 500 on checkout, allow overselling into negative stock
- checkoutSale's stock-availability loop called pb.getOne() with no
  try/catch, so a stale/deleted stock_item_id in a persisted cart
  threw all the way up to a bare 500 with no useful message. Now
  caught and returned as a clear pb_error the till can display.
- Removed the insufficient-stock block entirely: the till is used
  ahead of a physical stock take, so a sale must never be blocked
  for lack of stock — current_qty is now allowed to go negative and
  gets corrected at the next stock take. Drops the now-dead
  insufficient_stock/shortages branches from the route, service
  types, and client error handling.
2026-09-04 13:00:12 +02:00
b20651b12d fix(pos): replace undefined toast.error() calls with a real toast
confirmSale() called toast.error(...) on checkout rejection (e.g.
insufficient stock), but no toast global was ever defined anywhere
in the app, so the ReferenceError swallowed the intended error
message and operators saw nothing. Adds a minimal self-contained
showToast() and bumps the SW cache so tills pick it up.
2026-09-04 12:52:26 +02:00
2f3007d978 feat(pos): enhance POS configuration to handle missing API key prompts gracefully and add slip preview functionality in settings 2026-09-04 11:55:38 +02:00
5c9f30dbd1 feat(pos): 'Print slip' on account sales auto-routes to delivery note (network + BT) 2026-09-04 10:40:48 +02:00
79cc7d1f0a fix(pos): keep edit-line modal from getting stuck on invalid qty; show clear 409 stock-shortage error instead of silently queuing offline 2026-09-04 10:37:00 +02:00
46d5d540a0 feat(pos): letterhead service + 2 API endpoints + vat_percent on Letterhead type 2026-09-04 09:13:53 +02:00
3b5891f0c5 chore(pos): bump SW cache to v17 so reprints work offline 2026-09-03 18:29:11 +02:00
20e460644b feat(pos): wire history reprint to redirect to receipt page 2026-09-03 18:28:55 +02:00
095a3433fa fix(pos): expose onclick-bound functions on window so buttons actually work 2026-09-03 18:24:37 +02:00
c4bf4a24ab chore(pos): cache epos-print.js in the PWA app shell 2026-09-03 17:54:48 +02:00
d5a48226ff feat(pos): print delivery notes over the network transport 2026-09-03 17:54:41 +02:00
bee80d8ec8 feat(pos): print sale receipts over the network transport 2026-09-03 17:54:21 +02:00
aec8e50f54 feat(pos): add printer type/IP settings UI to receipt page 2026-09-03 17:53:58 +02:00
06abd3c551 feat(pos): send ePOS-Print jobs over HTTPS to network printers 2026-09-03 17:53:32 +02:00
93eeea15e8 feat(pos): add ePOS-Print XML builder for sale receipts 2026-09-03 17:53:23 +02:00
4f54051b66 feat(pos): add canvas-based logo rasterization for ePOS-Print 2026-09-03 17:52:47 +02:00
2ea76f9d9a feat(pos): add printer settings persistence for network printing 2026-09-03 17:52:39 +02:00
ca6e9364a8 fix(pos): resolve duplicate const d declaration breaking receipt.html script 2026-09-03 17:52:30 +02:00
aed3f5c861 feat: implement discount functionality in POS system
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
2026-09-03 12:19:21 +02:00
bdd0ac46e3 feat: implement per-route service worker selection
Updated the service worker registration logic to allow for specific service workers based on the route. Added support for a mobile service worker at `/m`, while maintaining existing functionality for `/counter` and `/new-order` routes. This enhances the PWA experience by serving optimized service workers for different app sections.
2026-09-02 07:49:38 +02:00
e1f14d9c48 fix(boss-dashboard): make /m installable as a PWA
manifest-mobile.json had an empty icons array, which blocks Chrome's
install prompt entirely. Point it at the existing android-chrome
192/512 icons and override appleWebApp.title to "Boss" so iOS
Add-to-Home-Screen matches the manifest's short_name instead of
inheriting the root layout's "WFDASH".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:14:48 +02:00
e0480878a6 fix(pos): correct Font Awesome webfont URL in bundled CSS
The CSS lived at public/pos/fa/all.min.css but its url() references
used ../webfonts/..., which from that location resolves to
/pos/webfonts/... rather than /pos/fa/webfonts/. The woff2 was at
the latter, so the browser silently 404'd every font request and
the icons rendered as tofu boxes.

The original FA 6.5.2 CSS ships with ../webfonts/... because it
assumes the CSS sits in a sibling css/ subdirectory next to webfonts/.
Ours is one level deeper, so the path is now plain webfonts/....

Bump SW cache to v15 so installed tills pick up the corrected CSS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 18:04:14 +02:00
076e80ecb4 feat(pos): add FA icons to catalog, matched by category
Each stock item now renders a Font Awesome icon in the catalog grid
in place of the generic '?' initials. The icon comes from the item's
category, set on the categories collection's new 'icon' field (e.g.
'fa-solid fa-bolt' for Fasteners). All 10 active categories seeded
with sensible defaults; can be edited per-row in PB admin.

Match logic lives in pos-service.listActiveStockCatalog: it pulls the
categories matcher table, then assigns each item by:
  1. category.match_brands (against item text or supplier's supply_tags)
  2. category.match_supplier_tags
  3. category.match_keywords (longest first so 'hydraulic hose' beats 'hose')
  4. the 'Unknown / Other' catch-all is excluded from matching — anything
     with no match simply gets the initials fallback

The matcher table is in PB (the existing match_keywords/match_supplier_tags
/match_brands fields on categories), so admins can tune coverage without
a deploy. Two keyword additions to Fasteners (allen cap, set screw) and
Cutting Tools (tap, drill, chuck) lifted the live match rate from 67% to
71% (298/418 items across 8 categories).

FA 6.5.2 Free, solid only, bundled under public/pos/fa/ (256KB total:
100KB CSS + 156KB solid woff2). Both the CSS and the woff2 are in the
service worker APP_SHELL so icons render offline. No CDN dependency.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 16:40:02 +02:00
a956b70137 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>
2026-08-31 02:25:05 +02:00
c56961d7d9 chore(pos): bump SW cache to v13, skip /api/ caching, scope manifest to /pos/
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:48:49 +02:00
c6e35ad5d7 feat(pos): delivery-note slip shows company name and optional prices
The Include-prices toggle drives both the on-screen slip and the
Bluetooth thermal print, so what prints matches what the operator saw.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:48:22 +02:00
58f06175e1 feat(pos): rewire cashup.html to /api/v1/pos/sales
Method split now reads pos_sales.payment_method directly. The four
method rows cashup already renders (cash/card/eft/account) would have
been permanently R0 for eft and account under a cash/card-only backend.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:46:26 +02:00
fac417a697 feat(pos): rewire history.html to /api/v1/pos/sales
Payment method now read from pos_sales.payment_method rather than
sniffed out of an order's notes with regexes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:45:43 +02:00
600b3ee3bf feat(pos): rewire index.html product count to /api/v1/pos/catalog
Also surfaces a rejected API key explicitly instead of letting it read
as an offline blip.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:44:55 +02:00
e8e65aabb7 feat(pos): checkout via /api/v1/pos/checkout, drop orders/order_items/delivery_notes writes
Sends client_ref (the till's localId) so the offline queue can retry a
sale that already landed without recording it twice. Customer search
moves to /api/v1/pos/customers and now carries the company name for the
delivery-note slip.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:44:31 +02:00
47f4c73f18 feat(pos): load catalog from /api/v1/pos/catalog instead of PB directly
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:42:42 +02:00
7d0d50408c feat(pos): replace hardcoded PB superuser creds with POS_API_KEY config
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:42:24 +02:00
78f357de69 chore(pos): copy WFSALES POS mockup into public/pos/ (unwired)
pb-config.js deliberately not copied — it hardcoded a PocketBase
superuser email/password in client-shipped JS.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:41:54 +02:00
b755ad34d6 feat(boss-dashboard): add /manifest-mobile.json PWA manifest 2026-08-28 00:15:00 +02:00