Commit graph

131 commits

Author SHA1 Message Date
DEls
1559f9e51f fix(purchasing): restore PO-number normalization in process-batch
Task 14 rewrote process-batch to fill in the placeholder PO row instead
of creating a new one, but dropped normalizePoNumber along the way. The
route it replaced normalized (uppercase, collapse whitespace, strip a
leading PO prefix) before both the dedup lookup and storage; the rewrite
used the raw OCR string for both.

That's a real regression, not a style choice: two OCR runs of the same
real order that extracted slightly different formatting -- "PO-1234" vs
"PO 1234" -- would both pass the exact-string dedup filter as
non-matches, silently defeating the one check that exists to catch
exactly that. purchase_order_number carries a UNIQUE index but that only
blocks an identical string, not a format variant of the same PO.

Confirmed src/app/api/purchase-orders/ingest/route.ts (the only other
caller of purchase_order_number in this shape) is dead -- deprecated in
docs/superpowers/specs/2026-08-19-po-import-queue-redesign.md, superseded
by process-batch, kept only in case anything still hits it. process-batch
is the one route n8n actually calls, so this was live-path drift, not a
cosmetic inconsistency.

Normalizes once, in validateExtracted(), so every downstream use (dedup
filter, stored value, response body) sees the same canonical form. Also
restores the old route's guard against a PO number that strips to empty
once normalized (e.g. a raw value of just "PO") -- non-empty raw input
that would otherwise sail through as a legitimate-looking fill-in.

Full suite: 580/580 passing, 0 failures -- the pre-existing 404-vs-500
test bug that predated this entire restructure was also resolved as a
side effect of Task 14's rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:29:06 +02:00
DEls
0124c612d3 feat(purchasing): process-batch fills in the PO, merges on dedup
Task 13 moved PO creation to upload time (placeholder PENDING- number,
real site chosen by the uploader). process-batch now fills in that
existing row instead of creating a second one, with three outcomes:

1. Fill in — the extracted number is new: update the placeholder in
   place (number, supplier, dates, line items, processing_status).
   `site` is never part of this update — the uploader's choice is far
   more reliable than OCR's guess at a project reference (an audit
   found OCR-derived site matching succeeds on ~8% of real documents).
   The OCR's project guess lands in raw_ocr_data.extracted_project_ref,
   not in a schema column.
2. Merge on dedup — the number already belongs to a different,
   already-processed PO: delete the placeholder (it would otherwise
   strand as an unresolved PENDING- row) and point the caller at the
   real PO.
3. Invalid extraction — mark the placeholder `failed` with
   `last_error` set, but keep it. A stranded failed row is visible and
   recoverable in the admin UI; a deleted one silently loses the file.

Contract changes from { batch_id } to { purchase_order_id }; matchSite
and its import are dropped (site is no longer OCR's job in this
route). Rewrote route.test.ts for the new contract, which also
resolves the pre-existing "404 when batch does not exist" test bug
(the old mock error had no .status, so the route's status-based 404
check fell through to 500) since that test's shape no longer exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:23:56 +02:00
DEls
4f9af4a2a6 fix(purchasing): restore the 30-day PO dedup window with a real timestamp
Task 13 moved PO creation to upload time and needed a hash-dedup window
matching the spec's "same 30-day dedup window as today" (design doc
section 4.1). purchase_orders has no working created/updated autodate
field on this PocketBase instance, and unlike delivery_notes it had no
other timestamp field either -- the first cut of Task 13 correctly found
this, but responded by dropping the time window entirely rather than
adding one. That's a real behaviour change from spec, not just a
workaround: a PO file legitimately re-sent long after the original would
have been flagged a duplicate forever, with no way to age out.

Added purchase_orders.date_uploaded (additive, mirrors how
delivery_notes already uses its own date_received) and wired it through:
stamped at create, filtered on in the dedup query.

Also brought PurchaseOrderRecord in pb-types.ts in line with what Task 1
already put on the live schema and what the upload route now writes --
processing_status/attempts/last_error/next_retry_at/last_attempt_at/
file_hash/date_uploaded were live in production but absent from the
TypeScript interface. docs/data-model.md section 7 documents this exact
gap as a standing foot-gun; closing it now while it's fresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:17:42 +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
928ed26f7c refactor(delivery-notes): own the slip and the hash, drop the shadow batch
delivery_notes already creates its own row at upload time with the slip
attached, and n8n's OCR poller already reads delivery_notes directly by
processing_status=pending — it never looked at data_import_batches. The
batch row created alongside every upload was therefore a pure duplicate
write: same file, uploaded twice, read by nothing downstream. Move dedup
onto the DN row itself (it has file_hash from Task 1) and delete the
batch-tracking code paths in both the upload route and the OCR-result
webhook.

The plan's suggested dedup filter/sort used `created`, but this PB
instance has no usable created/updated autodate fields on delivery_notes
(sort=-created 400s here, as already noted for purchase_orders and
flagged by Task 6). Used date_received instead, matching the field the
rest of the app already sorts this collection by (src/lib/agent/load.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:48:30 +02:00
DEls
0cdf385df2 refactor(schema): rename received_items and timesheets to their real names
RECEIVED_ITEMS -> DELIVERY_NOTE_ITEMS (55 rows): they are the lines on a
delivery note; the old name described an event, not a thing.

TIMESHEETS -> TIMESHEET_ENTRIES (1367 rows): this collection is the time
log, not a timesheet. The name is freed for the uploaded-document
collection created in Task 6.

PocketBase stores relations by collection id, so every relation survived
untouched; row counts asserted intact on both renames.

Two call sites the plan missed: src/app/api/timesheets/import/route.ts
and src/lib/ocr-result-tier.ts. Deliberately preserved the "timesheets"
string literals in pb-types.ts and the import route — those are
data_import_batches.batch_type select values, not collection names, and
changing them would have violated the select constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:01:40 +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
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
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
DEls
56f6dd8efc feat(plant): implement independent pagination for plant log entries
Added a new pagination system for the "Plant log entries" table in PlantView, allowing for separate page management from the main plant data. Introduced constants for log page size and updated the rendering logic to accommodate paginated logs, enhancing user experience when navigating through log entries.
2026-09-02 12:36:55 +02:00
DEls
602c62ba06 refactor(plant): enhance PlantPage and PlantView for fuel log integration
Updated PlantPage to aggregate vehicle data from fuel_logs, including hours worked and total value calculations based on odo readings. Adjusted PlantView to support navigation to vehicle details and improved log display with new data structure. Removed legacy code related to plant_hours_log and refined data handling for better performance and clarity.
2026-09-02 12:36:10 +02:00
DEls
377335af4f feat(vehicles): implement DELETE endpoint for vehicle removal
Added a DELETE API endpoint for hard-deleting vehicles, including error handling for foreign-key constraints. Updated tests to cover successful deletion and various error scenarios. Enhanced the PlantPage component to aggregate vehicle data and display KPIs, including total hours and values. Updated PlantView to handle optimistic UI updates for deletions.
2026-09-02 11:52:39 +02:00
DEls
b04c1f0b69 fix(fuel): keep fuel-limit text on a single line
The 'fuel limit' cell text (e.g. '8 L per hour limit') was wrapping
to two lines in the table and on mobile cards. Add whitespace-nowrap
and a title tooltip in all three places fuel-limits renders:
- FleetMobileCard edit-mode (line ~1056)
- FleetMobileCard non-edit branch (line ~1106)
- FleetRow desktop table cell (line ~1312)
2026-08-31 14:20:41 +02:00
DEls
545343e086 fix(fuel): keep alert/warn count badges on a single line
The 'X alerts' and 'X warns' badges in AlertCard and AlertListRow
had no whitespace-nowrap, so '2 alerts' could wrap to two lines on
narrow viewports. Add whitespace-nowrap in both AlertCard and
AlertListRow variants of the alert-red and warn-amber badges.
2026-08-31 14:19:04 +02:00
DEls
8013fa109e fix(fuel): tighten Alert + Fleet cards on smaller screens
Tighten padding, gap, and rounding on the alert cards and the
Fleet mobile card so they take less vertical and horizontal real
estate on narrow viewports. Layout restores to the roomier
current sizing once the screen reaches the 'sm' breakpoint (640px).

Per-card changes:
- AlertCard (mobile): gap-3 -> gap-2 sm:gap-3; p-4 -> p-3 sm:p-4.
- AlertListRow (desktop list): px-4 py-3 -> px-3 py-2 sm:px-4 sm:py-3;
  gap-3 -> gap-2 sm:gap-3.
- FleetMobileCard (mobile): gap-2 -> gap-1.5 sm:gap-2; p-3 -> p-2.5
  sm:p-3.
2026-08-31 14:17:02 +02:00
DEls
475dd77c42 fix(fuel): truncate vehicle description in Alert + Fleet rows
The leading text in alert cards (mobile + desktop) and fleet rows
wrap to a multi-line layout when the description + project badge +
status pill can't all fit horizontally. The inner flex containers
holding the description span already had 'truncate' on the span,
but were missing 'min-w-0', so the flex children couldn't shrink
below their content size.

Add 'min-w-0' to four inner flex containers:
- AlertCard top row (mobile alert cards)
- FleetMobileCard edit-mode meta row (project badge + fuel-limits +
  UnitTypeSelect)
- FleetMobileCard non-edit meta row (project badge + UnitTypePill +
  fuel-limits)
- FleetRow top cell (desktop fleet row, Description or Link)

Add native 'title' attribute on the three truncatable description
spans (AlertCard, AlertListRow, FleetMobileCard non-edit) so the
full vehicle name is available on hover as a fallback for the
ellipsis truncation.
2026-08-31 14:14:23 +02:00
DEls
dc04987454 fix(inline-edit): stop wireMap leaking camelCase unitType on wire 2026-08-31 13:55:32 +02:00
DEls
d4862b37e7 feat(inline-edit): add wireMap option for snake_case keys; plant Type column 2026-08-31 13:11:10 +02:00
DEls
e79538ab96 feat(plant): filter vehicles to unit_type=plant + cost_per_hour>0 at server 2026-08-31 13:07:22 +02:00
DEls
8394084648 feat(fuel): add Type column to Fleet table (desktop + mobile) 2026-08-31 13:04:19 +02:00
DEls
dfc85385d1 feat(fuel): filter vehicles to unit_type=fuel at server, plumb unitType 2026-08-31 13:00:55 +02:00
DEls
395c0ff114 feat(chat): sidebar link, editable-while-waiting, sticky footer, limit warning
Three concerns from testing:

1. Chat typing UX: the textarea was disabled while a request was in
   flight, so the user couldn't draft the next question. Removed the
   disabled attribute; Enter is just a no-op while busy. Send button
   still gates actual submission.

2. Sticky footer: switched the chat wrapper to 100svh (mobile-keyboard
   aware) and made the composer sticky bottom-0 with a solid background
   so it no longer fades into the scroll area.

3. Limit-warning: the SSE stream now reads the 'done' event and surfaces
   a banner when the server reports the agent loop hit its iteration
   cap, so a quiet stream end isn't mistaken for a complete answer.

Sidebar: added 'Ask' nav item pointing at /dashboard/chat with the
Sparkles icon so the chat is reachable from the dashboard even if the
standalone-PWA install path keeps misbehaving.
2026-08-30 23:51:45 +02:00
DEls
e45d19900d fix(pwa): emit apple-mobile-web-app-capable for iOS Add-to-Home-Screen standalone mode
Next 16's appleWebApp metadata API only emits `mobile-web-app-capable`,
which iOS Safari ignores for Add-to-Home-Screen standalone mode. The
home-screen icon launches the page inside regular Safari with full
browser chrome instead of as a standalone app.

Adding the iOS-specific `apple-mobile-web-app-capable` tag to the root
layout's <head> so the home-screen bookmark launches without Safari UI.

Harmless on non-iOS browsers (those use `mobile-web-app-capable` which
Next already emits).
2026-08-30 23:28:48 +02:00
DEls
5a7d2931f9 feat(pwa): render chat icon set + per-route apple-touch-icons 2026-08-30 22:15:15 +02:00
DEls
e528589e9c feat(chat): save a single answer as PDF via the print dialog
Hides app chrome via a 'printing-answer' class on body, leaves only the
.print-target visible. The class is removed in a finally so a cancelled
print dialog can't leave the app invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:09:06 +02:00
DEls
7d066f37dc feat(chat): take or attach a photo and ask about it
Adds a camera button next to the paperclip that opens the device camera
on phones (capture='environment') and a file picker otherwise. Photos
are read into base64 and sent with the next user turn.

Pending photos render as a thumbnail strip above the composer with a
remove button each. The state had to be hoisted above the composer
handlers — useCallback closes over it at handler-creation time, so
declaring it lower in the file produces a TS2448.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:03:21 +02:00
DEls
c5dbb6cf64 feat(chat): accept images in a user turn on both providers
Anthropic adapter: image content block before text.
OpenAI-compatible adapter: image_url with a data: URL.
Route normalizer carries images through the trust boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:57:52 +02:00
DEls
4902340b5f fix(pwa): declare the root manifest via metadata so routes can override it
The root layout hardcoded <link rel="manifest" href="/manifest.json">
in its head. Next's metadata API cannot override a hand-written tag, so
/dashboard/chat emitted two manifest links — browsers honour only the
first, making which app installed depend on tag order.

Declaring it as metadata.manifest lets the chat route's own manifest
replace it. Each route now emits exactly one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 09:07:44 +02:00
DEls
d40b7eb516 feat(pwa): install the chat as a standalone app
The chat page now exports Metadata that points at /chat.webmanifest and
declares appleWebApp for Safari's Add to Home Screen path (which
ignores manifest scope rules).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 08:58:30 +02:00
DEls
e63c653886 feat(chat): upload a purchase order from the composer
Reuses the existing /api/purchase-orders/upload endpoint with a 20 MB
cap, SHA-256 dedup, MIME allowlist and a status poll. The model never
enters the write path — the human uploads, and the agent only reads
the result. read-only.test.ts is unmodified.

Also made the paperclip button more visible: a plain grey-on-grey
icon is easy to miss for a primary input. Now it has a bordered
treatment consistent with the send button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 08:57:37 +02:00
DEls
fb612f0e6f feat(chat): add purchase order upload card
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 08:53:34 +02:00
DEls
342b56ee92 feat(imports): return the purchase order number from batch status
The status endpoint reports a PB record id, which is meaningless to the
chat upload card. Resolve it to the readable order number when the
batch type is purchase_orders, and leave the label null otherwise.

Failure to resolve the PO (e.g. it was deleted) is swallowed — the
batch status still stands and the raw id is still in the response as
the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 08:53:24 +02:00
DEls
b69900fc1b feat(chat): render answers as dashboard-grade markdown
The models return GFM — tables of vehicles, bold figures, lists — and
the view rendered it as preformatted text, so users saw raw pipes and
asterisks.

Answers now render through react-markdown with every element mapped to
this app's own conventions: tables get the borders, muted header and
hover of components/ui/table.tsx, and numeric columns get tabular-nums
so rand figures line up as they do on the fuel and sites pages.

Alignment is computed per COLUMN, not per cell — the table inspects its
own rows, decides which columns are numeric, and publishes that through
context so a header never ranges left above a right-ranged figure.

Layout: questions are compact chips, answers are full-width documents
with a thin amber rule. Bubbles fight tables. Each answer carries a
provenance strip naming the tools it used — for a tool whose value is
that its numbers reconcile with the dashboard, the source is part of the
answer.

Motion is limited to a streaming caret and the running-tool pulse, both
disabled under motion-reduce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:00:35 +02:00
DEls
801c2d7c5c fix(chat): stop the second message of a conversation from crashing
The browser sends assistant turns as {role, text} with no toolCalls, so
both adapters threw 'Cannot read properties of undefined (reading
length)' on the second message. The ChatEntry type guarantees nothing
across a JSON boundary.

Normalizes history at the route (the trust boundary) and makes both
adapters defensive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:50:36 +02:00
DEls
c543713ae8 feat(chat): add streaming chat UI at /dashboard/chat
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:41:31 +02:00
DEls
d7b5d01333 feat(chat): add env-gated SSE chat route
Returns 404 unless ENABLE_CHAT=true. No authentication yet — must stay
disabled in production until phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:41:31 +02:00
DEls
2a548e9ebc refactor(fuel): build rows from the shared variance pass
No behaviour change — deletes the duplicate group/sort/pairwise walk now
that computeFuelLogStatuses does it. computeFuelVariance now has exactly
one caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:04:51 +02:00
DEls
f966a7315a fix(dashboard): honour alert dismissals and stop truncating at 500 rows
The overview built its own fuel-variance walk that never read
attention_dismissed_at, so a vehicle dismissed on /dashboard/fuel stayed
on the overview forever. It also fetched fixed_costs and fuel_logs with
getList(1, 500), silently undercounting past 500 rows and skewing the
monthly cost tile.

Now uses the shared computeVehicleAttention/groupAttentionBySite and
fetches both collections in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:04:51 +02:00
DEls
a1263b39e6 refactor(fuel): use the shared RATE_PER_LITRE constant
Removes the last two of the four hardcoded 25.65 declarations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:47:30 +02:00
DEls
da8d19ab55 refactor(sites): use shared metrics on the site detail page
Detail and list now resolve start date identically, so projected total
and projected profit agree between the two surfaces. Timesheets are
filtered to this site before feeding the start-date calculation, so
another site's timesheet can no longer set this one's start date.

Deletes eight now-dead local helpers (parseDurationMonths, pickStartDate,
isoWeekKey, monthKey, expenseDate and the three series builders) along
with the duplicate RATE_PER_LITRE and WORKING_DAYS_PER_MONTH consts.

SiteStatus on the detail view gains 'upcoming', which the shared
projection can now return; without the matching STATUS_COPY entry the
header badge would dereference undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:47:30 +02:00
DEls
14d62eb34c refactor(sites): use shared metrics for the sites list roll-up
Replaces the inline per-site roll-up with computeSiteSpend /
resolveSiteStartDate / computeSiteProjection, and drops the redundant
second employees fetch now that the first one selects the site field.

Two correctness fixes in the same wiring:

- fuel_logs was fetched with fields 'id,site,litres', so every row fed
  to the start-date resolver carried an undefined date_logged and was
  silently skipped. Now selects date_logged.
- The resolver was only given fixed costs and fuel logs, while the
  detail page gives it all four sources. For a site with no
  job_start_date whose earliest timesheet or income claim predates its
  earliest cost, the two pages would still have disagreed. Timesheets
  are now bucketed by site via the employee join, and income_claims is
  fetched, so both surfaces see identical candidates.

Also drops fixed/payroll/material/fuel from the returned summary — they
are not declared on SiteSummary and no component reads them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:47:22 +02:00
DEls
0ea0257edb fix(plant-hours): surface real PB field-level error in import response
formatPbError walked err.data but PB's ClientResponseError wraps the
field-level error object at err.data.data. The outer err.data has
just three keys (data, message, status), all 'invalid' on a failed
insert — useless to operators. Read err.data.data (and
err.data.data.fieldErrors if present) so the operator sees WHICH field
rejected the row.
2026-08-28 14:43:29 +02:00
DEls
ad12a3c01c feat(plant): weekly hours log + attention required on /dashboard/plant
Adds two new sections below the existing equipment table:
- Attention required: rows where the row's vehicle unit_type doesn't
  match vehicle_unit_type_at_import. Operator can delete the row from
  here.
- Weekly plant hours log: per-week entries with hours, budget,
  cost/hr, value, and over-budget badge.

The topbar gets two new buttons (Download template, Import plant
hours) sitting next to the existing EditModeToggle.
2026-08-28 13:40:30 +02:00
DEls
a214012d99 fix(plant-hours): restore spec-mandated warnings[] in import response
Task 8's implementer dropped warnings[] in favour of a mismatched
count, but the design spec mandates warnings[]. Task 11's
ImportPlantHoursButton consumes warnings[] and crashes at runtime
when the response is parsed. Restore warnings[] (one entry per
mismatched-unit-type row, plus any schema-level warnings from
validatePlantHoursRow) while keeping the useful mismatched count.
2026-08-28 13:36:46 +02:00
DEls
c9789d89f4 feat(vehicles): PATCH /api/vehicles/[id] accepts unit_type
Lets the inline-edit flow on /dashboard/plant set the vehicle's
unit_type (fuel|plant) so the plant-hours import route's type-mismatch
warnings can be resolved by reclassifying the vehicle.

Adds a route.test.ts mirroring the attention-dismiss precedent: 11
cases covering happy paths for unit_type='plant'/'fuel', invalid
unit_type dropped (with/without other valid fields), existing
description+project_code behaviour preserved, JSON parse errors,
empty-payload errors, and PB write failures.
2026-08-28 13:27:22 +02:00
DEls
b22badb4f0 feat(sites-list): include one-off overheads in per-site spent total 2026-08-28 13:24:46 +02:00
DEls
1909c5c3d3 feat(plant-hours): DELETE /api/plant-hours/[id] route
Removes one plant_hours_log row. Used by the Attention Required panel
and the weekly log table's row menu.
2026-08-28 13:23:58 +02:00