Commit graph

82 commits

Author SHA1 Message Date
DEls
cb463c0faa feat(workforce): add timesheet exports and duplicate protection
Add per-site CSV and PDF matrix reports, editable sick-note date ranges, and weekend calendar styling. Preserve verified duplicate uploads for audit while excluding their entries from hours and costs, and prevent repeat files and overlapping daily imports.
2026-10-07 23:53:47 +02:00
DEls
a21c728d61 feat(workforce): add calendar and sick notes 2026-10-07 14:27:44 +02:00
DEls
f72397259c feat(auth): add Office access and restrict Clock users 2026-10-07 14:22:57 +02:00
DEls
8d53e3688c feat(workforce): add active and inactive workers 2026-10-07 13:41:05 +02:00
DEls
6824d2c62e Add Workforce attention alerts and 24-hour correction times 2026-10-04 23:38:43 +02:00
DEls
ed69b517e8 Integrate agent work and add dashboard attention indicators
Remove repeated Plant, Financial and Workforce shortcut cards. Add site-scoped Plant and Financial sidebar attention status with automatic refresh.

Include accumulated delivery processing, purchase order recovery, supplier reconciliation, plant data and user management changes and audit records.
2026-10-04 21:53:32 +02:00
DEls
4ad262b508 Let site managers create clock users for assigned sites 2026-09-30 00:11:24 +02:00
DEls
11a227bb4e Allow clock users to upload purchase orders for assigned sites 2026-09-29 13:33:46 +02:00
DEls
f5259d852f feat(auth): add PocketBase logins and site access controls 2026-09-28 23:51:47 +02:00
DEls
da9dca7c6e feat(plant): add "Add vehicle" button to the Fleet & Fuel page
Generalizes the Plant page's add-dialog into a shared AddVehicleButton
component parameterized by unit_type, and wires it into the vehicles
page against the existing POST /api/vehicles endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 08:33:53 +02:00
DEls
1899ed8e2c fix(dn): unstick long OCR runs, preserve OCR data through manual saves
Tick path
- Bump STALE_CLAIM_MS from 10 to 30 minutes. A 29-source OCR batch
  hit ~29 min and was being reclaimed mid-run by the 10-minute cap,
  leaving the batch in inbox with last_error='reclaimed after a
  stalled run' even though nothing was stalled. MAX_ATTEMPTS still
  bounds a truly stuck batch.
- After each dn_batch_pages create, the staged runner bumps
  dn_batches.page_count in the same lease-fenced batch. Final
  assembly write at run-staged.ts:223 still sets the authoritative
  value, so any drift from a reclaim-then-restart cycle is corrected.
  Inbox tiles now show live progress instead of 0 pages for the
  entire reading window.

PocketBase batch transport
- sendRecordBatch chunks across pb.createBatch() calls at the
  server 50-request cap. A 71-candidate assembly sent as one
  request was returning Invalid batch request data. and 500ing —
  the assembly route caught it and surfaced as the generic Grouping
  could not be saved. 73 mutations now split into 50 + 23 and land.

Assembly save UX
- The dismiss dialog now renders directly under the open-pages alert
  instead of at the bottom of the section (which was hidden behind 71
  document tiles on big batches). Clicking Dismiss page opens the
  reason input where the user is looking.
- saveGrouping surfaces the server-side error code
  (stale_revision / posted_candidate / dismissed_candidate /
  assembly_in_progress / invalid_page_coverage) instead of a generic
  Try again. On success, the page now redirects to /inbox so the
  documents land in their columns.
- The Technical details disclosure is gone from the grouping screen.
  Open pages already surface their reason in the alert above; the
  duplicate disclosure just competed with the decision UI.

Assembly route correctness
- After a successful manual save the route now writes
  stage=complete, status=needs_attention, notes_detected,
  processing_completed_at and clears claimed_at/last_error. The
  runner advances stage on its own runs but a manual save was
  never followed by a runner pass — without this the batch stayed
  in Check page grouping forever even though assembly was done.
- For existing candidates the route only updates grouping metadata
  (pages, page_from, page_to, assembly_revision, status, stage,
  checks). OCR-derived fields (doc_number, supplier_name_raw,
  items, raw_text) are preserved on the row — the runner only
  re-fills those for batches in status=inbox, and post-save the
  batch moves to needs_attention and never reaches the runner
  again. New candidates still get a createdCandidateFields row
  with the OCR fields blank, ready for the runner or operator
  entry.
- A Re-run batch button was added to the document detail header
  so the operator can refill the wiped OCR data (or re-resolve
  checks after a manual override) without first navigating to the
  batch detail page.

Customer identity check
- A Skip this check toggle on the document detail page records
  manual_overrides.skip_customer_identity. run-staged reads the
  override and turns the customer_identity check into passed=null
  (skip) instead of passed=false (fail), so OCR false-positives
  on the customer VAT no longer block posting while still
  requiring an explicit operator decision.
- The PATCH route accepts skip_customer_identity as an editable
  field so the toggle saves through the standard note route.

Tests
- 180 files / 1767 tests pass; typecheck clean.
2026-09-27 13:40:27 +02:00
DEls
0f022d5ffa fix(ask): keep the Asking-as bar pinned under the navbar
The Ask page sized itself as 100svh-4rem, but the Topbar is 89px on
desktop and 167px on phones, so the page overflowed and the bar scrolled
under the header. The Topbar now publishes its live height as
--topbar-h and the Ask page fills exactly the space below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:56:40 +02:00
DEls
3feddebcaa fix(mobile): audit and fix layout overflow across dashboard pages at 375/768px
Audited /dashboard, /dashboard/sites (+ detail), /dashboard/workforce/workers,
/dashboard/workforce/on-site, /dashboard/plant/vehicles (+ detail),
/dashboard/plant/equipment (+ detail), /dashboard/plant/fuel-logs,
/dashboard/financial/purchase-orders (+ detail),
/dashboard/financial/delivery-notes/inbox, /dashboard/financial/suppliers,
and /dashboard/chat at 375x812 and 768x1024 using a scroll-right test to
catch real horizontal page overflow (as opposed to a table's own,
intentional internal scroll).

Two repeating bug patterns accounted for every overflow found:

1. A page-specific `right={<div className="flex items-center gap-2">...}`
   passed to Topbar can carry two or three labelled buttons (edit/import/
   merge/back etc.). Without `flex-wrap` they don't fit a 375px screen in
   one row and push the whole page sideways. Fixed on the vehicles,
   equipment, and site detail pages, plus their list-page counterparts
   (vehicles fuel-view, equipment plant-view) — all five instances of
   this exact pattern. Same fix applied to a delivery-note card's button
   cluster on the PO detail page, and to Topbar's own right-side
   container as a general backstop.

2. `Intl.NumberFormat("en-ZA", ...)` joins currency/number groups with
   non-breaking spaces, so a wide value like "R 24 988 663" can't wrap.
   In a 3-up grid (site cards' Contract/Spent/Remaining) that overflowed
   the column; fixed by stacking those to 1-up below `sm`. In a 2-up grid
   (PO detail KPI tiles) added `min-w-0` so the tile clips instead of
   growing the grid track past the viewport.

Also: the shared Dialog now caps at `max-h-[90dvh]` with internal
scroll (a dialog taller than the phone viewport had no way to reach
its close button or footer before), and the sidebar hamburger button
was overlapping the Topbar's title on every page below `lg` — reserved
space for it instead.

Tables already scroll horizontally inside their own card via the
shared `<Table>` component or an `overflow-auto` wrapper, and KPI
grids already collapse responsively — both were fine everywhere checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:44:19 +02:00
DEls
efc1ed61d0 fix(layout): keep the Topbar pinned with iOS safe-area padding
The Topbar was already `sticky top-0`, which tracks the whole-page
scroll fine on both phones and desktop since neither the dashboard
layout nor any page wraps it in a clipping scroll container. What was
missing was the iOS PWA notch: add `paddingTop: env(safe-area-inset-top)`
so the bar's own background paints under the status bar/notch in
home-screen standalone mode instead of leaving a transparent gap.

z-30 stays below the z-40/z-50 used by drawers, dropdowns and dialogs,
and below the Leaflet map page's isolated stacking context (1760f77),
so the Topbar's dropdowns still float above the map and dialogs still
float above the Topbar.

Also fixes a real overlap bug surfaced while auditing at 375px: the
sidebar's fixed hamburger button sits at the same top-left corner as
the Topbar's title below `lg`, and nothing reserved room for it, so
the page title rendered partially underneath the button. Reserve that
space with padding instead, and truncate the title/subtitle so long
site names don't overflow the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:00:27 +02:00
DEls
bdb2b2361b feat(ask): log app change requests for the developer
file_change_request (tools-actions.ts) logs an ask_log row
(type "request", status "open") for a change to the app itself — a new
page, column, button or report layout — distinct from propose_changes,
which changes data; the prompt now routes between the two.
list_change_requests (tools.ts, read-only) lists them, which the
existing MCP server picks up automatically since it iterates the whole
tools array. /dashboard/change-requests lists and re-statuses them
(open/planned/done/declined) via new GET/PATCH routes under
/api/ask/change-requests, linked from the sidebar under Ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:07:27 +02:00
DEls
af71b8534b feat(suppliers): green-row tint, period filter, and KPI tiles
Green when a supplier's ordered > 0 and outstanding === 0 -- the whole
row, not just the number, so a fully-paid-up supplier stands out at a
glance.

Period filter next to the search:
  All time, This week, This month,
  Week 1-4 (of current calendar month, last absorbs any remainder),
  Month 1 (current) / Month 2 (last) / Month 3 (month before),
  Custom range with two date inputs.

Window anchors on delivery_notes.date_received -- liability accrues when
the goods arrive, not when the PO is uploaded ('we only pay what we
received this month'). computeSupplierBalances gains lastDeliveryISO per
row; a pure helper in supplier-period.ts resolves windows in UTC so the
boundary does not drift with the server's timezone.

Four KPI tiles at the top of the page:
  - Total ordered / delivered / outstanding (cumulative, always).
  - Payable this period (delivery value inside the active window).
    Subtitle is honest: 'subtract anything already paid' -- we do not
    track supplier payments, so this is the upper bound.

ui/select.tsx exports a SelectLabel so the period dropdown can group
options.
2026-09-26 17:38:39 +02:00
DEls
e83a0d302c fix: overview counts, wrong-unit fuel limits, meter guard, en-ZA numbers, vehicle merge, docs
- Overview/Plant tiles and pages share one vehicle bucket rule (blank unit_type = fleet)
- Plant landing equipment figures were always 0; hour diffs now reject 0 baselines and >24h/day jumps
- km/L limits on plant (and L/h on vehicles) report unit-mismatch instead of false alerts
- formatPercent/formatDecimal replace display toFixed (en-ZA)
- POST /api/vehicles/[id]/merge + Merge duplicate dialog (dry run first)
- Attention links go to equipment vs vehicles by unit type; PO upload view link fixed
- Conditional hook fixed in DN document view; ESLint flat config (next lint removed in 16)
- Docs: current modules/routes/env vars; login + Ask reports plans

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:04:08 +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
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
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
e7e92fdae9 feat(dn): store bulk uploads as one batch 2026-09-18 19:19:05 +02:00
DEls
90b2a01b6b feat(delivery-notes): let an operator pick the purchase order themselves
A blurred, torn or missing order number left a note with no route to its
purchase order. The pipeline resolves a PO from the printed number and
nothing else, so the note blocked on po_resolved and stayed there — five
notes are in that state right now, three of them with no order number on
the slip at all. The operator can read the supplier off the letterhead
every time and had no field in which to say so.

A searchable picker already existed but only rendered for old-flow rows,
which no longer occur, and it was handed supplier and projectCode as
empty strings — so the one search an operator actually performs matched
nothing. Both ends are fixed: the picker moves to src/components, and
buildPoChoices loads the supplier and the site's project code from the
records that hold them.

Choosing writes the order's number into po_number_raw, the same field
the free-text editor already edits, so the next re-run resolves it
exactly and the manual choice passes through every check like any other
note. Writing purchase_order directly would not survive: runBatch
recomputes that field from the number on every run.

The picker is also offered on a note that resolved to the WRONG order —
a number misread by one digit passes po_resolved while pointing at
someone else's delivery.

Verified against the live dev server: the inbox batch page now ships 25
purchase orders with real supplier names and project codes, newest
first, where it previously loaded none.

968 tests, typecheck clean. No component tests — this repo has none, and
the logic worth asserting on lives in po-choices.ts, which has eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 08:48:38 +02:00
DEls
a7c116c036 feat(delivery-notes): accept multi-file uploads (up to 50 per request)
Operators can now drop a folder of slips at once instead of uploading
them one at a time. Each file becomes its own inbox batch; the route
processes all of them in one request and returns per-file outcomes
(created / duplicate / error).

Backward compatible: single-file requests still get the legacy
response shape. New multi-file response shape:

  201 {
    results: Array<{ ok: true, batch_id, ... } |
    { ok: true, duplicate: true, ... } |
    { ok: false, error, filename }>,
    summary: { total, created, duplicates, errors }
  }

The UI input accepts multiple files; the result panel shows one row
per file with a colour-coded icon.

8 new tests cover: per-batch outcomes, >50 files rejected,
mixed MIME types, byte-identical duplicates within one request,
site lookup short-circuits before per-file work.
2026-09-17 18:46:51 +02:00
DEls
5e88ebe410 feat(delivery-notes): upload creates an inbox batch, not a delivery note 2026-09-17 14:31:22 +02:00
DEls
eba3f8c89a feat(clock): in-app PWA install prompt with Chrome + iOS branches
The kiosk phone needs the app installed for the full-screen
standalone experience, but no browser auto-prompts on day one —
Chrome's mini-infobar waits for an engagement heuristic that's
too slow for the kiosk use case, and iOS Safari never fires
beforeinstallprompt at all.

New InstallPrompt component mounted in the clock layout (so it
only appears on /clock, not the dashboard). Three branches:

  * Chrome / Edge / Android: defer beforeinstallprompt, surface
    our own Install button that calls prompt() on tap.
  * iOS Safari: detect iOS user agent and show 'Tap Share then
    Add to Home Screen' (Apple doesn't fire the install event).
  * Already installed: detect display-mode:standalone +
    navigator.standalone and render nothing — silent on the
    kiosk itself.

Dismissal is per-session (sessionStorage), so a fresh visit on
a new device re-shows the prompt. The banner sits above the
clock footer (bottom: 6rem) so it doesn't fight the Person /
Fleet tabs for taps.
2026-09-14 11:17:54 +02:00
DEls
978ddf03cc feat(workforce): add "On site now" page derived from MEGA Clock scans
A supervisor-facing view of who's currently clocked in, per site — a
live muster list built entirely from the existing clock_events data
(no schema change). Lives in the dashboard next to Workers/Timesheets,
not on the kiosk phone, since it's a manager tool rather than
something a worker scanning their own badge needs to see.

The per-employee "latest event" reduction is shared via
src/lib/clock-events.ts so the on-site page and the Workforce
landing page's new "On site now" KPI tile stay in sync by
construction rather than by copy-pasted logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:59:43 +02:00
DEls
59136a0935 fix(auth): return to the originating page after PIN unlock 2026-09-12 00:14:55 +02:00
DEls
30a45f759d Merge branch 'feat/site-scope' into main
Global site scope: one switcher in the topbar, carried in the URL as
?site=, resolved server-side via the getSiteScope() seam, and applied as
a PocketBase site= filter across every dashboard module. Replaces the
four independent per-table project-code dropdowns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/superpowers/plans/2026-09-11-site-scope.md
2026-09-11 23:57:51 +02:00
DEls
0c29049324 fix(site-scope): close two latent leaks and the stale scope pill
Three review findings, all of which break the moment `allowed` stops
being every site — i.e. as soon as PocketBase users land.

siteIdFilter(): the Overview spelled its `sites` filter out as
`mode === "all" ? "" : id="..."`, which drops the `unrestricted` check
that makes "all" safe and would load every site for a manager allowed
only a subset. Both field variants now go through one shared function so
the check cannot be forgotten again. Covered by six new tests.

Site pickers read scope.allowed: upload and create dialogs took their
options from unfiltered `sites` reads, so a restricted user could
attribute a PO, delivery note, timesheet or plant item to a site they
cannot even open — a write path, not just a display leak. View scope
must not narrow a picker, but permission scope must. Also drops one
redundant sites query on the equipment page.

useScopeFallback(): the pill fell back to a value the layout computes
once, and layouts do not re-render on navigation — so after any link
that doesn't carry ?site= forward (24 of them), the pill read
"All sites" over a single site's figures. It now reads the live cookie,
which the switcher keeps current, fixing every link rather than the four
scopeAwareHref already patched.

Verified against the running app: scope survives a client-side
navigation through a bare link, and the Overview reads 1/"The Hills
ext 12" scoped and 2/"Across every site" unscoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:56:28 +02:00
DEls
4ac5f3a220 fix(site-scope): preserve ?site= across sidebar navigation
Bug: picking a site in the topbar pill updated both URL and cookie,
but the sidebar nav Links were plain paths ('/dashboard/sites',
'/dashboard/financial/purchase-orders', ...). Clicking any of them
stripped the '?site=' query string. The page's getSiteScope() then
fell back to the still-set cookie and filtered the data correctly,
but the pill's useSearchParams() and the layout's frozen fallbackValue
both produced mode='all' — so the topbar pill showed 'All sites · N'
while every table was filtered to the picked site. The visual pill
and the data disagreed on every screen the user clicked.

Fix: extract scopeAwareHref() and route every sidebar Link through
it. The helper carries the current '?site=' (and nothing else) into
the destination URL, so the URL stays the single source of truth
for both the page filter and the topbar pill across navigation.

Layouts do not re-render on navigation, so preserving the URL is
the only viable fix — making the layout re-run getSiteScope() on
every navigation is what the plan explicitly avoided by design.

Tests: 5 new unit tests for scopeAwareHref covering the bare-path,
scope-only, drop-other-params, url-encoding, and 'all' sentinel
cases. Total: 651 tests passing, typecheck clean.
2026-09-11 23:41:11 +02:00
DEls
729118d5dd feat(site-scope): exempt the Sites list, add a per-row scope action 2026-09-11 23:20:05 +02:00
DEls
6609ce8e50 feat(site-scope): add the topbar scope switcher 2026-09-11 23:03:10 +02:00
DEls
63eb8ed931 feat(site-scope): load the allowed site list in the dashboard layout 2026-09-11 23:02:30 +02:00
DEls
5195398a00 feat(sites): add work-month-start-day field to the site form 2026-09-11 22:25:32 +02:00
DEls
41c4b41046 feat(project-calendar): add shared Month/Week badge component 2026-09-11 22:24:45 +02:00
DEls
f80c30d369 feat(financial): wire up per-collection upload status pages
Adds the "Upload status" buttons on Purchase Orders and Delivery Notes,
the shared badge/KPI/filter UI they use, and removes the now-dead
Uploads sidebar entry and Financial landing card.
2026-09-11 21:44:12 +02:00
DEls
73e19ad5d2 feat(workforce): workers roster slim + QR badge card + batch export 2026-09-11 20:57:52 +02:00
DEls
b454621ecf feat(suppliers): add Suppliers to the Financial sidebar group 2026-09-11 18:20:34 +02:00
DEls
50bcf405a9 feat(suppliers): add create/edit dialog 2026-09-11 18:16:40 +02:00
DEls
1d706e1985 feat(dashboard): grouped sidebar navigation with landing pages
Implements docs/superpowers/specs/2026-09-11-sidebar-restructure-design.md.
Reorganizes the flat 7-item sidebar into Workforce/Plant/Financial groups,
each with a landing dashboard and sub-pages split out of the former
combined Employees and Purchasing pages. Old routes (/dashboard/employees,
/fuel, /fuel/[vehicleId], /plant/[vehicleId], /purchasing,
/purchasing/[poId]) now redirect to their new locations so existing
bookmarks and links keep working.

New pages: Workforce, Plant, Financial landing dashboards; Timesheets
(entries table + upload, surfacing document status that was previously
invisible); Fuel Logs (flat table across both vehicle types); Delivery
Notes (the page docs/superpowers/specs/2026-09-11-ingestion-restructure-design.md
section 8 deliberately deferred); Uploads (combined PO/DN ingestion status).

Moved-unchanged pages (Workers, Vehicles, Equipment) verified
byte-identical to their prior content via direct diff against main --
relocated only, nothing altered.

Reviewed the initial implementation and found two issues, both fixed
here before commit:
  - The delivery-note review queue (NeedsReviewDns) was duplicated onto
    the Purchase Orders page instead of moving to the new Delivery Notes
    page as specified -- same workflow rendered in two places from two
    independent queries. Removed from Purchase Orders; it now lives
    exclusively on Delivery Notes.
  - The new Uploads page's timestamp column fell back to a `created`
    field that doesn't exist on this PocketBase instance, so it could
    never help. All 24 real purchase_orders predate date_uploaded being
    backfilled and would have shown a blank column forever. Dropped the
    dead fallback; the view already degrades to an em-dash for missing
    timestamps.

Typecheck and full suite verified clean before and after both fixes
(583/583).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:47:27 +02:00
DEls
a3e4702159 refactor(schema): resolve project_code through site, not a stored copy
project_code was a text copy of the parent site's code, denormalised
onto 8 child collections that each already have a real site relation.
Written once at insert, never updated -- docs/data-model.md documents
the drift risk. On purchase_orders it never even held a real project
code: production had requisition numbers, site names, and the PO's own
number in that field.

Every read site now resolves the code through site/expand.site/siteById
instead, reusing each page's existing site map where one already
existed. Every create() call stops writing the column. The chat agent's
list_imports tool no longer reads data_import_batches -- it now reads
timesheets, fuel_sheets, plant_hours_sheets, purchase_orders and
delivery_notes filtered by processing_status, which was necessary before
the pool can be dropped (Task 20 would otherwise 404 the agent).

Also closed a Task 17 gap found while doing this: the agent tools' vehicle
and fuel-log handlers still resolved project code from the now-removed
vehicles.project_code column.

Wrote scripts/migrations/2026-09-15-drop-legacy-columns.py, the one
irreversible step in this whole restructure. Its guard against orphaning
fuel_logs rows checked the wrong condition -- "fuel_sheet is empty" --
which produced a false abort: 73 fuel_logs rows have no fuel_sheet
because they predate batch tracking entirely and never had a
batch_reference to begin with, so dropping data_import_batches costs
them nothing. Fixed the guard to check what it actually claims to check:
a row that HAD a batch_reference and was never relinked. Verified live
that no such row exists on any of the three affected collections.

Script run in dry-run only; not yet applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:01:14 +02:00
DEls
fa725f096c feat(purchasing): create the PO row at upload with a placeholder number
purchase_orders.purchase_order_number carries a UNIQUE index
(idx_po_number, confirmed live) so two unprocessed POs can no longer
share "". The upload route now creates the purchase_orders row
directly (dropping the shared data_import_batches pool for POs),
stamping it with a placeholder minted by src/lib/po-placeholder.ts
that n8n's extraction overwrites once OCR completes. Also requires
`site` at upload instead of waiting on OCR-derived site matching,
which only succeeds on ~8% of real documents per the project's audit
- the uploader already knows which site they're on.

Dedup now checks purchase_orders by file_hash directly. This
instance's purchase_orders collection has no working created/updated
autodate field (confirmed live - sort/filter by `created` 400s) and
no other timestamp field, so the dedup query drops the 30-day window
used by the old batch-based dedup and instead excludes only rows
whose last attempt failed.

process-batch/route.ts is intentionally untouched - it still creates
a fresh purchase_orders row from a batch, which is now inconsistent
with this upload shape. That inconsistency is expected until the
follow-up task rewrites process-batch to fill in the row born here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:14:19 +02:00
DEls
819678fb06 fix(timesheets): document-first import, site stamped per entry
Cuts the timesheet importer over to the document-first pattern already
proven for fuel logs and plant hours: creates a `timesheets` document
pending (site required, unlike the other two sheet types) before any
parsing happens, guards every early-exit path with a failDoc() helper
that writes a real `failed` outcome, and links entries via the
`timesheet` relation instead of `batch_reference`.

The actual point of this task: each timesheet_entries row now stamps
its own `site` from that row's employee (falling back to the
document's site only when the employee has none), rather than
inheriting the upload's site. Site attribution for payroll history is
resolved once, at write time, so moving a worker to a different site
later no longer rewrites their historical labour cost at read time.

Also updates ImportTimesheetsButton to the documentId response shape
(previously batchId), matching the Task 10/11 button updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:07:01 +02:00
DEls
49f119ab5c fix(fuel): document-first import with honest status
The fuel-logs import route created its data_import_batches audit row
with status "processed" hardcoded before a single row was inserted —
the same production bug fixed for plant-hours in the prior commit.

Cut the route over to fuel_sheets (Task 6/7): the document is created
`pending` with the source file attached before any parsing, and the
real outcome is computed from actual insert counts via
resolveDocumentOutcome() and written back before the response
returns. A sheet whose vehicles match nothing now comes back `failed`
with rows_created: 0, not `processed`. Every fuel_logs row now links
via the `fuel_sheet` relation instead of `batch_reference`.

`fuel_sheets.site` is optional (a pump sheet can span sites; the
row-level site resolved from "Current Site" stays authoritative), so
it's only passed to the document create when the form supplied one.

Also renames ImportResult.batchId -> documentId in
import-fuel-logs-button.tsx to match the new response shape. No
exception-report route or separate orchestrator lib exists for fuel
logs (unlike plant-hours), so there was nothing else to rename.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:02:02 +02:00
DEls
610426776a fix(plant-hours): document-first import with honest status
The plant-hours import route used to create its audit row with
status "processed" hardcoded before a single insert ran. Production
ended up with 19 such batches, 18 of which created zero rows while
claiming success.

Cut the route over to plant_hours_sheets (Task 6/7): the document is
created `pending` with the source file attached before any parsing,
and the real outcome is computed from actual insert counts via
resolveDocumentOutcome() (Task 8) and written back before the
response returns. A sheet whose vehicles match nothing now comes back
`failed` with rows_created: 0, not `processed`.

Also renames plant_hours_log's `batch_reference` writes to the new
`plant_hours_sheet` relation (src/lib/plant-hours-import.ts), and
renames the exception-report route folder from [batchId] to [sheetId]
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:56:11 +02:00
DEls
8317238184 fix(purchasing): proxy PB files so PDFs render in the slip iframe
PocketBase sets `Content-Security-Policy: ...sandbox` on /api/files,
which makes Chromium treat the response as opaque-origin and silently
fail to render PDFs inside an <iframe> (downloads still work). Add a
same-origin proxy at /api/pb-files that forwards the bytes and strips
the CSP / X-Frame-Options headers so the iframe can render.

Files:
- new: src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts
- SlipPreview now uses /api/pb-files/... for PDFs and PB directly
  for images (<img> isn't subject to the CSP sandbox, so the direct
  URL works there unchanged).

Path validation, ALLOWED_COLLECTIONS gate, path-traversal guard, and a
stable Content-Disposition so downloads keep the original filename.
2026-09-07 14:53:22 +02:00
DEls
f936efa356 Merge branch 'feat/dn-upload-hash-dedup' into main 2026-09-07 14:51:37 +02:00
DEls
78fa8da05c feat(delivery-notes): dedup by SHA-256 at upload time
Mirrors the existing /api/purchase-orders/upload pattern onto the
delivery-note path so byte-identical re-uploads short-circuit with
`200 + { duplicate: true, existing_dn_id, ... }` instead of
creating a second delivery_notes row.

Concretely:
- Hash the bytes with SHA-256 before any PB round-trip.
- Query `data_import_batches` for any processed batch with the same
  hash inside a 30-day window (the same window the PO route uses; the
  underlying `idx_batches_file_hash` index already covers it).
- On hit, return the existing DN + batch ids and skip creation. The
  n8n OCR pipeline is never invoked for a duplicate slip, so a
  double-clicking operator costs us nothing.
- On miss, stamp `file_hash` on the new batch row so subsequent
  re-uploads can dedup against it.
- Wire the response through `UploadDeliveryNoteButton` as a new
  'duplicate' panel showing the existing DN id plus a quick link to
  /dashboard/purchasing where the awaiting-PO inbox lives.

pb-types: adds file_hash?: string to DataImportBatchRecord so the
column the PB schema already has is reflected in the TS shape.
2026-09-07 14:49:33 +02:00
DEls
2d0f1b6703 feat(purchasing): add searchable picker for PO assignment
Replaces the static Select in the needs-review delivery-note modal
with a small PoPicker component that lets you type a PO number,
supplier, or project code to filter the list. The picker keeps the
same chosen-id contract so the assign flow is unchanged.

No new dependencies: a controlled input + filtered <ul> +
outside-click handler covers the use case without pulling in Radix
Combobox.
2026-09-07 14:20:16 +02:00
DEls
08ed89d9c8 feat(dashboard): enhance operations dashboard with rain outlook and coordinate support
Updated the operations dashboard to include a rain outlook feature, providing users with a 7-day forecast based on site coordinates. Added latitude and longitude fields to the site form for better weather data integration. Enhanced the fuel log and site management functionalities to utilize dynamic fuel pricing based on historical data. Improved overall data handling and user experience across various dashboard components.
2026-09-06 19:59:52 +02:00