Commit graph

6 commits

Author SHA1 Message Date
c8da9daf94 fix(scan): speed up QR recognition and keep scanning 2026-10-05 09:43:53 +02:00
f10020705c fix(scan): prevent camera support render loop 2026-10-05 08:38:37 +02:00
77346e7716 feat(delivery-notes): invoice a scanned note directly, or quote it
Whether a counter sale is invoiced or quoted is a PER-CUSTOMER
commercial decision, and it belongs to whoever is at the delivery
notes list in WFDASH — not to someone standing at the van with a
phone. So the scanner is now capture-only, and the choice lives in
the list.

The scanner keeps exactly three things: read the note, reprint it,
photograph the signed copy. The ladder buttons (Mark as loaded,
Capture PO, Move to ready to invoice) are gone from it, along with
the state they needed.

In the list, the `$` action was a permanently-disabled placeholder
reading "Invoice — coming soon (v3-D)". It is now real, sitting next
to Q (create quote) on the same row so the operator picks one or the
other per customer.

Getting there needed one substantive change. The invoice route had a
deliberate guard:

    "Ad-hoc delivery notes (no parent order) cannot be invoiced
     through this endpoint."

which is why the button was a placeholder — a POS-raised counter
sale has `order: null`, so it could only ever be quoted, never
invoiced. The stated reason was that "the QB webhook requires an
order_number to attach the invoice to", but that requirement is
about a non-empty reference, not about it coming from an order:
`buildQbInvoicePayload` types `orderNumber` as optional and falls
back to "". The route now always supplies one — operator override,
else the parent order's number, else the delivery note number, which
is stable, unique and traceable. That same resolved value replaces
the `dn.expand.order.order_number` read in the synthetic invoice-
number fallback, which would otherwise have thrown on an ad-hoc note.

The detail page's "Invoice via QuickBooks" button was also gated on
`expand?.order`; it now shows for any non-terminal note and opens
via `?invoice=1`, so the list links to it rather than duplicating a
financial modal (QuickBooks customer search + billing-company toggle
+ order number) that would then have to be kept in step.

Also adds a Signed column to the list. No new status value: the
`draft` status is left alone as agreed, and "paperwork is back" is
shown from `signed_copy` itself, linking straight to the file. That
is what makes a scanned-and-signed note distinguishable from a
freshly-created one in the list.

No lint errors added — both set-state-in-effect errors in the two
delivery-note pages are pre-existing and verified unchanged against
HEAD.
2026-10-04 17:55:13 +02:00
7b844b2e51 fix(scan): /scan had no way to log in, and blocked LAN access was stale
Two separate reasons the phone could not use the scanner, only one of
which is about auth.

1. NO LOGIN PATH. `(app)/layout.tsx` is the app's auth gate — it
   redirects an unauthenticated user to /login. /scan is a separate
   route group and inherits only the ROOT layout, which provides
   `AuthProvider` — and `AuthProvider` merely EXPOSES the session, it
   never renders a login form. So /scan rendered the scanner for
   anyone, the first API call came back "Not authenticated.", and
   there was no way forward from the page: the operator had to guess
   that /login existed somewhere else in the app. Now gated to
   `/login?next=/scan`, with the path run through `isSafeNextPath`.

   The gate lives in its own client component because `metadata`
   cannot be exported from a "use client" module — caught by loading
   the page, not by tsc.

2. THE LAN ENTRY WAS BLOCKED, and the allowlist could never have
   worked. `allowedDevOrigins` is matched by `isCsrfOriginAllowed`,
   which splits the host on "." and compares label by label, treating
   `*` as exactly ONE label. Checked against Next 16.2.6:

     isCsrfOriginAllowed("192.168.1.35", ["*"])        -> false
     isCsrfOriginAllowed("192.168.1.35", ["192.168.*"]) -> false
     isCsrfOriginAllowed("192.168.1.35", ["192.168.1.*"]) -> true

   So the `"*"` that had been sitting in the list for months did
   nothing, and the list hard-coded three individual LAN addresses
   (.42, .122, .128) that go stale whenever the router hands out a new
   DHCP lease. When the lease moved to .35 the dev server refused the
   phone's dev resources and the page hung on the spinner forever.
   Now wildcards the last label of the subnet.

Also separates the two failure states the UI conflated: "Camera
blocked — this page is not on HTTPS" is now distinct from "this device
has no camera", because on a phone over plain HTTP those look
identical and the first is the one you can actually fix.
2026-10-04 14:44:48 +02:00
80608602ad fix(scan): the QR was being downscaled below its own resolution
Scanning felt like it never resolved. It was not slow — it was being
downscaled into unreadability.

The printed QR is 28pt on a 210mm page: 4.7% of the page width. The loop
down-scaled the camera frame to 640px wide before decoding, so a phone
pointed at a full A4 sheet rendered that QR at ~30px total, about 1 pixel
per module. Measured against the real rendered page, jsQR cannot read it
at any crop size:

    cap    px/module   whole frame        bottom-45% ROI
     640      1.04      MISS               MISS
    1280      2.08      miss               miss
    1600      2.60      HIT  69ms          HIT  28ms
    1920      3.12      HIT  98ms          HIT  39ms
    2560      4.16      HIT 171ms          HIT  39ms

Above ~2.5px per module it is reliable, so the cap is now 1920. No amount
of speeding up the loop could have fixed this; the pixels were not there.

  - requestAnimationFrame instead of a 120ms setInterval. rAF fires
    straight after a painted frame, so the decoder gets a fresh one
    rather than whichever frame happened to be current at the tick; it
    also removes the 120ms floor on every detection and lets a
    readyState stall recover on the next frame instead of a timer tick.
  - Two regions instead of one. The delivery-note QR sits in the bottom
    band of the page, so that band is scanned every frame at roughly a
    third of the cost of the full frame (39ms vs 98ms). The full frame
    runs every 6th as a safety net for a tilted page. It is an
    accelerator, not a restriction.
  - Native BarcodeDetector is now EXCLUSIVE when present. It used to
    fall through to jsQR on a miss "because jsQR sometimes succeeds
    where native blurs", which doubled the cost on every miss — the
    common case. The native detector is Google's own ML stack; when the
    browser ships it, use it alone.
  - Screen wake lock while scanning, re-taken on visibilitychange
    because the browser drops it whenever the tab hides. Otherwise the
    phone locks mid-queue.
  - 3s debounce per code, and the camera keeps running after a hit, so a
    stack of notes can be worked through without a tap per sheet.

The viewfinder is now ONE <video> mounted for the life of the page,
resized when a note loads. A second copy in the note branch would have
unmounted the first — and since a MediaStream is attached to the
ELEMENT, the replacement video would have come up blank while the stream
kept running into nothing.

Four new tests pin the resolution floor, including one that documents
that a full A4 page framed at 640px is UNREADABLE, so nobody lowers
MAX_SCAN_EDGE back to 640 thinking it is just a performance knob.
2026-10-04 13:51:44 +02:00
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