Three document collections replacing data_import_batches for the
spreadsheet importers. Each owns its source file and carries honest
outcome counters (rows_total/created/failed, row_errors) — the counts the
current importers compute and then discard.
site is required on timesheets (a wage sheet belongs to one site) and
optional on the other two, which legitimately span sites since the fuel
importer resolves a site per row. cascadeDelete is false on all three, so
deleting a site cannot destroy its source spreadsheets.
Widened source_file mimeTypes beyond the plan's three entries to match
the pool being replaced. Browsers label CSVs text/csv, application/csv or
text/plain interchangeably and the importers branch on file extension,
not MIME — so the narrower list would have parsed a CSV successfully then
rejected it on write. The 2026-08-24 migration exists because that
already happened once.
Plan corrected: its TypeScript edit added a second `expand` property to
an interface that already had one, which does not compile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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.
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.
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.
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.
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.
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.
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.
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)
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.
The Type column badge had no whitespace-nowrap, so 'Fleet (L)' /
'Plant (h)' wrapped inside the pill whenever the table column was
narrow. Add whitespace-nowrap to the badge className so the label
stays single-line at any column width.
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.
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.
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.
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).
Probing 'combined value of the top three delivery notes' against the
production model returned R 3 285 785 — arithmetically correct, exactly
what the ban was written to prevent.
The previous prompt said 'never do arithmetic' but did not say what
to do instead when the user explicitly asks for an aggregate. The
model picked the path of least resistance and summed them.
New prompt lists the verbs that count as arithmetic (including average,
percent, ratio, weighted, combined, total, sum) and gives the model a
concrete response shape: report each row's value, declare the
aggregate 'not available from me', tell the user which number is
missing.
Re-probed — the model now lists the three values individually and
declines the sum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Closes the coverage gaps: delivery notes, plant hours, fixed costs,
income claims, the employee roster and import batches. Each takes an
optional site, an enumerated sortBy and a limit, so list/highest/lowest
questions work across every collection.
Verified live against the dashboard:
- list_delivery_notes sorts by value; top result AS0002086 at R3 255 444
- list_employees returns 72 (16 permanent + 56 casual)
- list_imports returns 44 batches, none failed
- list_plant_hours returns 0 rows — same as /dashboard/plant
(the production plant_hours_log collection is empty today)
- list_fixed_costs / list_income_claims wired through same shape
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six new loaders: delivery notes (with received items bucketed for
value computation), plant hours, fixed costs, income claims, employees
and import batches. Each takes optional filters rather than full
queries, so callers always run through typed tool handlers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
delivery_notes stores no value; a note is worth the quantity received on
each line times the matched PO item's rate. Unmatched lines contribute
zero rather than a guessed rate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Queried by two dashboard pages and the agent loader, but previously
untyped — those reads compiled only because untyped getFullList returns
loose records.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
Brings the tool set to 11. All five build on the same metrics pass the
dashboard uses, so per-fill variance, per-worker totals and the
underlying records reconcile with the pages they sit next to.
Also: sort='-created' on purchase_orders returns HTTP 400 from
PocketBase for this collection. Switched to -required_by_date, which
sorts by ordering recency — closer to what a project manager wants
than document creation time anyway.
Caught by calling list_purchase_orders against live data; the test
suite does not cover the live sort syntax.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both build on the same metrics pass the dashboard uses, so per-fill
variance and per-worker totals reconcile with the pages they sit next
to. No new business math — the data already existed, but no tool
exposed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors resolveSite: id, then QR code, then exact description, then
substring — and ambiguity is reported, never guessed. Picking the wrong
vehicle silently attributes fuel spend to the wrong machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
MiniMax M2.x emits its chain of thought inline in `content` wrapped in
<think> tags, so the user watched the model reason at itself before
every answer. Anthropic returns reasoning as separate thinking blocks,
so this is a provider quirk and the filter lives in the OpenAI-compatible
adapter only.
Tags can straddle chunk boundaries ('<thi' + 'nk>'), so the filter is a
small state machine that holds back a short tail rather than emitting
text that might turn out to start a tag. Verified character-by-character.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Each adapter imports exactly one SDK. index.test.ts pins the env gate as
fail-closed: only the exact string 'true' enables chat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every tool figure is computed by src/lib/metrics and matches the
dashboard. A number the model derives itself would look authoritative
and reconcile with nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>