Commit graph

559 commits

Author SHA1 Message Date
DEls
e01c45765b feat(clock): dry-run historical shift pairing 2026-09-25 03:16:08 +02:00
DEls
5529c3f53d feat(clock): add locked shift schema migration 2026-09-25 03:13:06 +02:00
DEls
488eb2af99 feat(clock): define local workday and shift records 2026-09-25 02:03:46 +02:00
DEls
e68e2d5dab docs(clock): plan daily shifts and exception review 2026-09-25 01:56:35 +02:00
DEls
d694dde501 docs(clock): design daily shifts and missed scan review 2026-09-25 01:50:25 +02:00
DEls
47ec4f1d1b feat(purchase-orders): enhance PO handling with improved validation and item management
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.
2026-09-23 23:37:37 +02:00
DEls
844ba8fe12 feat(workforce): enhance on-site dashboard with detailed employee status
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.
2026-09-23 00:25:33 +02:00
DEls
0edd8503b1 refactor(clock): update ConfirmDialog layout and timing constants
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.
2026-09-23 00:08:32 +02:00
DEls
97fe75931d feat(clock): implement two-step QR scanning flow for employee clocking
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.
2026-09-22 23:50:59 +02:00
DEls
f88d5c519e feat(fuel-logs): enhance vehicle auto-creation and error handling
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.
2026-09-22 23:47:28 +02:00
DEIs
1760f77cff fix(map): isolate Leaflet stacking context so the Topbar dropdown sits above the map
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.
2026-09-22 22:12:49 +02:00
DEIs
87f75dd98f feat(plant): fleet map page with GPS-denormalised vehicles, clustering, and tile switcher
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.
2026-09-22 21:39:25 +02:00
DEls
a589b14ac9 feat(clock): enhance vehicle scan API with GPS support
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.
2026-09-22 19:59:44 +02:00
DEls
3077223d38 feat(clock): add MEGA Clock API endpoints and update documentation
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.
2026-09-22 12:42:39 +02:00
DEls
f1dbc96435 feat(dn): grab and move the order the same way as the slip
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>
2026-09-21 02:05:31 +02:00
DEls
8027b5a263 feat(dn): flip between the slip and the order it is checked against
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>
2026-09-21 01:44:02 +02:00
DEls
4c49e862d3 feat(dn): drag the zoomed slip, and size the controls for a thumb
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>
2026-09-21 01:34:50 +02:00
DEls
5a3d0eddbf feat(dn): read the slip beside the fields, not in another tab
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>
2026-09-21 01:29:52 +02:00
DEls
dae879b15a feat(dn,po): order purchase orders by supplier, and let a duplicate be set aside
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>
2026-09-21 01:24:09 +02:00
DEls
6bb0fb1da5 feat(po): colour the whole line, and give over-supply its own colour
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>
2026-09-21 01:19:15 +02:00
DEls
1c61a3fe96 feat(po): separate what is still owed from what was over-supplied
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>
2026-09-21 01:14:05 +02:00
DEls
1031a103c0 chore(dn): add two read-only checks for the staged rollout
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>
2026-09-21 01:00:44 +02:00
DEls
db15e27bb1 fix(dn): let a hand-linked line settle the question it answers
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>
2026-09-21 00:21:12 +02:00
DEls
b8dcf20146 fix(dn): settle one product line at a time, and keep the link
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>
2026-09-21 00:15:13 +02:00
DEls
14be7e7e21 fix(dn): keep an operator's correction through the next run
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>
2026-09-20 23:55:56 +02:00
DEls
7a02ab9bb7 feat(dn): work a delivery note on its own page, not its whole batch
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>
2026-09-20 23:50:24 +02:00
DEls
74a2a9de11 feat(dn): record quantity findings instead of refusing the delivery
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>
2026-09-20 23:50:10 +02:00
DEls
252dd7eb6a fix(dn): let staged ingestion run against the real PocketBase schema
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>
2026-09-20 23:49:58 +02:00
DEls
da756f5224 feat(worker-qr): real PDF + CSV batch export
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).
2026-09-20 20:43:10 +02:00
DEls
3a0aedec0d Merge branch 'codex/hybrid-dn-ingestion'
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>
2026-09-19 21:56:23 +02:00
DEls
df47e6d2ba docs: note queue server boundary runtime fix 2026-09-19 20:56:48 +02:00
DEls
03ac13d951 fix: keep queue model server safe 2026-09-19 20:56:25 +02:00
DEls
d00b5f1204 docs: record quoted index normalization verification 2026-09-19 19:37:18 +02:00
DEls
1a97d7db44 fix(migration): preserve quoted index semantics 2026-09-19 19:36:50 +02:00
DEls
485443a1e4 docs: record migration idempotency verification 2026-09-19 19:33:56 +02:00
DEls
bee2b3dd17 fix(migration): normalize PocketBase index definitions 2026-09-19 19:33:39 +02:00
DEls
d12ca92468 docs: record scoped ledger totals verification 2026-09-19 19:26:37 +02:00
DEls
bb70f3339a fix(dn): scope staged ledger totals to PO items 2026-09-19 19:26:20 +02:00
DEls
9fafec64b1 docs: record staged readiness follow-up 2026-09-19 19:23:40 +02:00
DEls
199dfba195 fix(dn): align staged readiness with ledger totals 2026-09-19 19:23:22 +02:00
DEls
0f910c1bf7 docs: record final delivery note fix verification 2026-09-19 19:17:46 +02:00
DEls
bbd7a7bb23 fix(dn): harden staged assembly and page identity 2026-09-19 19:17:46 +02:00
DEls
9ace0fbd3a docs: record audit identity fix 2026-09-19 19:00:06 +02:00
DEls
eed128ebb3 fix: bind posting audit to delivery note 2026-09-19 18:59:52 +02:00
DEls
4b0da73dcf docs: record audit and PO verification fixes 2026-09-19 18:58:25 +02:00
DEls
11bc1a4b6b fix: verify audit collisions and purchase orders 2026-09-19 18:58:07 +02:00
DEls
d2dc3a4262 docs: record posting fix verification 2026-09-19 18:54:38 +02:00
DEls
310219d09b fix: keep staged migration dry run additive 2026-09-19 18:54:24 +02:00
DEls
4b4c9b2590 docs: record delivery note posting fix round 2026-09-19 18:53:49 +02:00
DEls
2914eefacf fix: harden delivery note posting review gates 2026-09-19 18:53:25 +02:00