Updated the purchase order processing to include enhanced validation for item edits, ensuring that all required fields are checked and properly formatted. Introduced new functions for computing item amounts, taking into account discounts and VAT. Refactored the PO detail view to streamline item management, including a new ItemsCard component for better organization of line items. Additionally, improved the handling of PO number sanitization to strip leading and trailing characters, enhancing matching accuracy. This update aims to improve the overall robustness and user experience of the purchase order system.
Updated the on-site dashboard to provide a clearer overview of employee statuses. Renamed sections for better clarity, changing "On site now" to "On site today" and updated descriptions to reflect current employee activity. Refactored the data handling to categorize employees into three distinct groups: those currently on site, those clocked out today, and those who have not arrived. This change improves the visibility of employee attendance and enhances the overall user experience on the dashboard.
Removed the DialogFooter component and restructured the ConfirmDialog layout for improved usability. Increased AUTO_DISMISS_MS from 5 seconds to 30 seconds and adjusted DOUBLE_CONFIRM_WINDOW_MS from 4 seconds to 6 seconds to enhance user experience during the scanning process. Updated button styles for better visibility and interaction. This change aims to streamline the confirmation flow and reduce accidental dismissals.
Enhanced the PersonScanner component to support a two-step flow for scanning employee badges. The new process includes a lookup phase to identify the employee and their last scan status, followed by a confirmation phase where the operator can verify the scan before recording the clock event. This change addresses issues with accidental double scans and improves user feedback with warnings for recent scans. Updated state management and error handling to reflect the new flow, ensuring a smoother user experience.
Introduced a new function to infer vehicle unit type from descriptions, improving the auto-creation process for vehicles in the fuel logs. Enhanced error handling for missing identifiers during vehicle creation, providing clearer feedback on the reasons for failures. Updated the SheetJS parsing to prevent date misinterpretation and refined the PlantPage to display all plant vehicles, regardless of cost per hour, ensuring better visibility of available resources.
Leaflet's internal panes use z-index 200..1000 (tile, overlay,
marker, popup, controls). The map wrapper had no z-index of its
own, so those values leaked up to the dashboard layout and buried
the Topbar's `All Sites` dropdown — which sits at z-50 by default.
The Topbar itself is in a non-overlapping zone so it looked fine,
but the dropdown's content (portalled into document.body) dropped
into the map area and got hidden behind the tile pane.
Add `isolation: isolate` to the map wrapper so Leaflet's z-index
ladder is contained inside the wrapper as a single atomic unit.
External UI at z-50+ now floats above the map as expected. The
legend and layer switcher inside the wrapper are unchanged (still
z-[400]) — they remain above the tile pane and below the map
controls, which is where they were before.
No-op for every other page; the wrapper is only on /dashboard/plant/map.
Adds /dashboard/plant/map: a Leaflet view of every vehicle scanned from
the MEGA Clock kiosk, plotted at its last known position.
Schema
- vehicles.last_latitude / last_longitude / last_scanned_at /
last_location_accuracy_m denormalised off fuel_logs so the map page
renders every vehicle in a single query. Same best-effort pattern as
attention_dismissed_at; updated by /api/clock/vehicle-scan after every
successful scan. PB init defaults new number cols to 0 (not null) —
filter uses 'last_latitude > 0 || last_latitude < 0' to mean 'real fix'.
- Indexed via idx_vehicles_last_scanned.
Map UI
- Custom SVG markers per unit_type: car silhouette for fuel, tractor
silhouette for plant, 24x24, drop-shadowed.
- Clustering via react-leaflet-cluster over Leaflet.markercluster.
Cluster icons colour-ramp with size (green->sky->amber->rose) and
scale 32->54px.
- Three-button base-layer switcher: Street (OSM), Dark (ESRI Dark
Gray Canvas — CartoDB watermarks tiles without a key), Satellite
(ESRI World Imagery). Last choice persists in localStorage.
- Site pins: small purple map-marker divIcon, click flies the map to
zoom level 15.
- Empty-state is a floating chip on the map rather than a full-page
card so the map is always visible.
APIs
- /api/clock/vehicle-scan: stores the GPS fix on the fuel_logs row
AND denormalises onto the vehicle when present; missing-coord
scan leaves last_* untouched.
Sidebar: new Map entry under Plant group.
Updated the vehicle scan API to include optional GPS coordinates (latitude, longitude) and location accuracy. The API now validates these fields, ensuring both latitude and longitude are provided together and within valid ranges. Enhanced error handling for invalid inputs and updated the API reference documentation accordingly. Additionally, modified the FleetScanner component to capture GPS data and reflect its status in the UI.
Introduced three new server-only API endpoints for the MEGA Clock PWA:
- `POST /api/clock/scan` for clocking workers in and out via badge scanning.
- `POST /api/clock/vehicle-lookup` for looking up vehicle details using QR codes.
- `POST /api/clock/vehicle-scan` for logging odometer readings from scanned vehicles.
Updated the README and API reference documentation to include these new endpoints and their functionalities. Replaced the placeholder for fleet scanning in the ClockView component with the new FleetScanner component.
The order was a PDF in a frame, which meant the browser's viewer, not
ours: no drag, no shared zoom, controls in a different place. Two halves
of one comparison behaving differently.
Its pages are now rendered to PNGs through the same poppler path the
delivery note's pages use, so both sides go through one viewer and get
the same stepping, zoom, drag-to-pan and remembered width. Switching
sides resets to the first page at 100%, since the position held on one
document means nothing on the other.
The PDF route stays for "Full size". Its file lookup moved to lib and is
now shared with the page renderer, so the two cannot drift on which field
holds the file. Page count comes from pdfinfo rather than rendering, as
the viewer needs the count before it draws anything.
Verified against MEGCPO9239: a two-page order, both pages 200 image/png,
page three 404, no token 401.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying a delivery means comparing two documents, and the order was not
one of them: reaching it meant leaving the note, finding the purchase
order and coming back having lost the comparison.
The viewer gains a toggle. The order is a PDF, so the browser's own
viewer handles it — it already paginates and zooms better than wrapping
it in ours would. The toggle only appears when an order is resolved and
carries a file; an unmatched note has nothing on the other side of it.
The document is proxied behind a signed token rather than linked from
PocketBase, which would put the admin JWT in the browser. The token is
namespaced per document, so one minted for a delivery-note page cannot be
replayed against an order, nor one order's against another's — verified
against a live file: valid token 200, absent 401, replayed 401.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Grab and drag now pans a zoomed page, which is how anyone expects to move
around an image they have magnified — scrollbars were the only way
before, and they are a poor fit for reading a smudged order number.
Mouse only, deliberately. A finger already pans an overflowing box, with
momentum and edge behaviour that come free and feel native; taking that
over would replace something good with an imitation. The listeners live
on the window rather than the frame, because a fast drag outruns the
cursor and would otherwise leave panning stuck on when the button is
released outside it.
Controls were 28px, below the 44px a thumb needs. They are full-size on
small screens and compact from the sm breakpoint up, where a pointer
makes the smaller target fine. The frame also gives back some height on
a phone, where 70vh left nothing of the fields underneath.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matching a document meant opening its page in a new tab, reading it,
coming back and picking up where you left off — for every document, and
twice for a two-page one.
The slip now sits beside the fields it is being read into. Pages step
with prev/next and a page strip, zoom runs 100% to 400% against a
scrolling frame so inspecting a smudged order number does not push the
fields off screen, and the pane's width is a native resize handle with
the detail panel flexing into whatever is left. Full size is still one
click away for a genuinely unreadable scan.
Width is remembered: this page reloads after every save, and re-sizing
the slip each time would be its own small tax.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Purchase orders now sort by supplier, with unresolved suppliers last
because those are the ones needing attention rather than the ones to read
past. Sorted rather than grouped: at roughly one and a half orders per
supplier, headings would outnumber the rows they introduce. Worth
revisiting when one supplier routinely has enough orders to fill one.
Duplicate delivery notes were detected and then stranded. not_duplicate
compares supplier and document number, including alternate numbers, and
blocks the candidate — but the endpoint that resolves one has existed
since it was written with nothing in the app calling it, so a duplicate
sat in the queue permanently. The document page can now set one aside
with a reason, offered with the duplicate's own wording when that is what
triggered it.
Set aside, not deleted. A duplicate that arrived is a fact worth keeping:
if a supplier later says they delivered twice, the second note and the
reason it was set aside are the answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single coloured number at the far right of seven columns is easy to
miss. The description now carries the same tone as the outstanding
figure, so a line that still owes reads as one thing at a glance.
Over-supply moves from amber to blue, in the row and on the summary tile.
Amber sat next to red as another shade of "behind", which is the opposite
of what over-supply is: nothing is owed, something arrived that was not
ordered, and it is queried with the supplier rather than chased.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Outstanding is ordered minus delivered, so it turns negative once a
supplier delivers more than the order. Both read as amber, and an
over-supplied line showed a bare negative quantity — so the two problems
looked alike when they call for opposite responses.
Still owed is now red through the list. Over-supplied is amber and says
so in words rather than with a minus sign.
The summary tile clamps at zero, because nobody owes a negative delivery,
which hid over-supply entirely. It now names the over-supplied lines
instead of the fulfilment percentage and takes the warning tone. Raising
it on the order is the point: a delivery that physically happened is
still recorded, and the order is where the discrepancy is answerable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dn-snapshot prints row counts across the delivery-note collections. The
two that matter are delivery_notes and delivery_note_items: the staged
pipeline must never move them on its own, so a reading before and after a
test says plainly whether anything reached the ledger.
dn-board renders the inbox board as text, which is how the pipeline can
be read over SSH or while the app is mid-deploy.
Both are read-only. The other scripts written while diagnosing this work
were one-off probes and are not kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Linking every line left the document blocked, and reloading asked the
same questions again.
resolve-item stores the link but not the checks, so the stale
product_matched failure survived with its original decision list — which
is what the screen re-read on reload. It also judged readiness against
those same stale checks, so no number of links could ever make a document
ready.
The runner owns the checks, but it could not see the links either:
resolveNoteLines matches on wording and alias alone, so a line linked
without saving an alias was matched away again and the answered question
reopened. Manual links now reach the matcher through the check context
and count as matched, which is what makes product_matched pass.
Re-check is therefore a real re-check rather than a page reload, and runs
once when the last line is answered instead of once per line.
Verified on a 16-line delivery note with three lines linked by hand: it
now reaches ready_to_post with 16/16 linked and no failing checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answering the second product question returned batch_locked. Resolving a
line fired a full re-run, which holds the batch lease for some twenty
seconds, so the next answer arrived while the lease was still taken. The
re-run achieved nothing either: resolve-item already links the line,
re-evaluates the document and writes its stage.
It was also quietly destructive. A run rebuilds `items` from the page
reads, and only a choice saved as an alias can be re-derived from the
descriptions — so a link made without ticking "remember this alias" was
discarded by the next run, including the tick a minute later. Manual
links now persist in manual_overrides alongside the order number, and are
honoured only while the linked item still belongs to the resolved order,
so correcting the order cannot leave lines pointing at the previous one.
Each line now has its own Save rather than committing the moment the
dropdown changes, and an answered line leaves the list instead of
reloading onto the same screen with the same questions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking the right order did nothing. The route stored the corrected
number, the re-run rebuilt the candidate from its page reads, and
po_number_raw went back to the original misread — so the operator
answered the same question again, having been told it was saved.
Nothing in dn_batch_notes distinguished a value a person chose from one
the model read, so the runner could not tell a correction from stale OCR
and overwrote both. A manual_overrides field now records the decision,
and the runner applies it before resolving rather than after, so the
corrected number is what the PO is matched on.
Only fields a run rebuilds are recorded this way. Quantity, unit and the
masses survive on their own; page ranges are the assembler's answer and
are corrected through the assembly route, which keeps the whole batch
consistent. An override must carry a value — clearing a box is not a
decision that a document has no number.
Verified end to end on a slip whose order number was read with a zero for
the letter O: the correction survived the run, resolved the order, moved
the match from fuzzy to exact, and left the document ready to post.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A batch is the unit only while its files are being prepared, read and
grouped. Past that point holding its documents together means one
unresolved order keeps every settled sibling waiting, and clicking a
document to fix one order presented a page strip and five unrelated
notes.
The inbox board now changes unit where the work does: Queued, Reading
photos and Check page grouping hold batches; Needs matching, Ready to
post and Complete hold individual documents. Both stage ladders already
existed in PocketBase, so nothing needed migrating. Failed batches move
to their own strip rather than spending a column.
/document/[id] is the new home for one note — its pages, values, lines,
failures, order picker and, at last, a button that posts it. The post
endpoint has been there and gated since it was written with nothing in
the app calling it. The batch screen keeps page grouping and lists its
documents as links.
The re-run button ran runBatch regardless of DN_STAGED_INGESTION_ENABLED.
On a staged batch that was destructive rather than merely inconsistent:
runBatch reads the single dn_batches.file, so a five-photograph batch
would have been rebuilt from its first photograph alone. It now selects
the runner exactly as the tick does.
Save grouping only set a message. A save bumps the assembly revision and
deliberately blanks each candidate's commercial data for recomputation,
so the screen appeared to do nothing while quietly emptying six
documents, and the next save was rejected as stale against a revision the
operator never saw. It reloads now, as its neighbouring buttons already
did. Choosing an order re-runs the checks in the same action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A delivery note is a record of material that physically arrived. Refusing
to record it because its quantity cannot be reconciled with the order
leaves the yard's paperwork outside the system, so quantity findings are
now reported on the purchase order rather than gating ingestion.
qty_plausible and delivered_totals_available become informational: still
computed, still stored, never blocking. Only assembly and identity —
po_resolved, product_matched, units_convertible, not_duplicate — hold a
document back.
qty_plausible also stops guessing. Where no common unit exists it fell
back to the raw slip figure, comparing 30,260 kg against an order of 30
and reporting a 1,009x overage with "check the slip for a misread
decimal". The slip was right; the order had no unit. 61 of one site's 148
PO lines carry no unit, and units_convertible deliberately tolerates
that, so the two checks disagreed about the same gap. It now declines to
compare rather than produce a number that blames the wrong document.
The delivered-totals query OR-ed every relevant PO item id into one
filter sent as a GET parameter. Measured against production, 80 clauses
(3,436 chars) succeed and 85 (3,651) return a bare 400; that site's 148
lines put it permanently over, so no candidate could ever reach
ready_to_post. Chunked at 50.
Batch stage no longer counts candidates needing review. Grouping is a
batch question and matching is a document question, and folding them
together parked batches under "Check page grouping" with no grouping left
to check — holding settled notes behind one unresolved order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three faults stopped the staged pipeline from ingesting anything once it
met a live PocketBase rather than a test double.
PocketBase treats 0 as blank for a required number field, and two staged
fields legitimately hold zero: dn_batch_sources.ordinal is 0 for the
first source in a batch, and dn_batch_pages.region is 0 for a whole-page
read. Both creates were rejected with validation_required, so the first
file of every upload failed and no page ever persisted. The migration now
declares them optional, and pb-relax-dn-zero-fields.py relaxes instances
that already have them required.
The staged runner verifies its batch lease and commits the protected
write in one transaction, which needs PocketBase's batch API. That is off
by default, so the runner failed closed with
dn_pipeline_transactions_required and no deployment note explained why.
pb-enable-batch-api.py turns it on and raises the 3s transaction timeout,
which is tight for a commit carrying page images.
compare-dn-pipelines used top-level await while resolving as CJS, so the
command in the rollout runbook always failed to transform. Renamed .mts.
The tests missed all of this because they mock PocketBase, and the mock
accepts what the real server rejects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the off-screen CSS grid + window.print() in the worker QR
batch dialog with two on-click exports:
- Download PDF — A4 portrait, 2x5 grid of CR80 (85x54mm) cards per
page, brand orange header, generated with pdf-lib (dynamically
imported, same pattern as src/lib/photo-pdf.ts).
- Download CSV — UTF-8 BOM, columns employee_no, name, position,
worker_type, project_code, qr_payload via Papa.unparse.
The single-card print on the worker detail page still uses
window.print() — one card has no pagination problem, only the batch
view did.
Spec §4.4 updated to match the new output mechanism and explain the
mobile failure mode that prompted the change.
New tests (8) cover the CSV builder (header order, qr_payload ==
employee_no per spec §4.1, RFC 4180 quoting, empty input) and the PDF
builder (empty input throws, valid %PDF output, 11 workers paginate
onto 2 pages, fake PNG injection so layout is testable without qrcode).
Staged, photo-first delivery note ingestion: bulk JPEG/PDF intake, one
persisted vision read per page, deterministic assembly into candidate
documents, site-scoped supplier/PO resolution, split review, a
supplier-grouped work queue with manual line linking, and fenced
idempotent posting.
The new pipeline stays dormant behind DN_STAGED_INGESTION_ENABLED; the
tick route keeps running the legacy runner until the flag is set to
exactly "true". The PocketBase schema is already applied in production
(verified by a read-only dry run: every collection, field and index
present, no financial rows touched).
The local draft of the multi-file upload route is superseded by the
branch version, which parameterises its PocketBase filters, checks
duplicates before creating a batch, and rolls back a partially written
batch. That draft is kept in stash@{0}.
Verified on the merged tree: tsc --noEmit clean, 1200 tests across 106
files passed, next build passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>