Card is rounded-xl with no overflow-hidden. TableHeader is sticky with
its own opaque background (bg-card/95) that sits flush against the
card's edge -- with nothing clipping it, the header's square background
corners poke past the card's rounded ones. Visible on every page built
with the shared <Card><CardContent className="p-0"><Table> pattern:
Suppliers, Supplier Detail, Uploads, Delivery Notes, Fuel Logs,
Timesheets. Purchase Orders is unaffected -- it uses a card-per-row
layout, not an HTML table.
Added overflow-hidden to each affected Card. Standard, deterministic
Tailwind behavior -- reliably clips child content to the parent's
border-radius, no visual regression risk.
Also added click-to-sort on the Suppliers list (Supplier/POs/Ordered/
Delivered/Outstanding, toggling asc/desc) -- the page previously only
supported a fixed server-side default (outstanding descending) with no
way to change it. No existing sort-header convention exists elsewhere
in the app, so this is self-contained rather than a premature shared
abstraction for what is still a single use site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
New suppliers collection replacing purchase_orders.supplier (free text)
as the authoritative value, kept alongside it as OCR provenance rather
than dropped -- it genuinely is the supplier name, just inconsistently
formatted, unlike project_code which never held a real project code at
all.
Grounded in production: 24 POs, 19 distinct raw supplier strings, at
least 3 confirmed duplicates under a formatting variant. Migration
auto-merges only exact-normalized matches; anything ambiguous (e.g. two
"Vula" entries that may or may not be the same company) stays separate
rather than guessed.
The accounts-payable rollup (ordered/delivered/outstanding per supplier,
in Rand) is computed live from purchase_order_items and
delivery_note_items every read, never stored -- same reasoning as
po-fulfilment.ts: a stored balance has to be kept correct on every PO,
delivery, and correction, and PocketBase has no transactions to
guarantee that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pb-bootstrap-schema.py -- the script a fresh PocketBase instance would
run against -- had fallen ten migrations behind and still described the
pre-restructure world. Rewrote it field-by-field against a live
production dump (17 collections, every field/required-flag/relation/
index verified). Found and fixed two pre-existing gaps unrelated to this
restructure while verifying: fuel_prices and income_claims were entirely
absent from the bootstrap script.
pb-relax-rules.py's collection list updated to match.
No local PocketBase binary is available in this environment, so the
plan's "bootstrap fresh and diff against production" verification step
was replaced with a manual field-by-field comparison against the live
dump -- same goal, different mechanism, documented as a substitution
rather than silently skipped.
Added site scoping to the one agent tool that still lacked it
(list_imports, backed by loadImportBatches) -- every other list_* tool
already had it. Preparation for the future per-user site-allowlist work,
not part of this restructure itself.
Rewrote docs/data-model.md and docs/data-flows.md end to end: the
site-attribution table, the collection reference, the five ingestion
pipelines, and the schema-drift section that was describing the exact
problem this task fixes. Every remaining data_import_batches reference
in both docs is now historical context, not a current description.
This is the last task of the ingestion restructure
(docs/superpowers/plans/2026-09-11-ingestion-restructure.md).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The irreversible step of the ingestion restructure. Guards proved clean
(0 no-site rows across all 8 target collections, PLANT empty, all 27
migrated documents present, no row still depending on the old batch
link), full schema + row-data backups taken to /tmp/pb-backup-2026-09-15
first, then applied.
Dropped project_code from employees, vehicles, fixed_costs,
income_claims, purchase_orders, delivery_notes, fuel_logs and
plant_hours_log. Dropped qty_outstanding from purchase_order_items.
Deleted the PLANT collection (0 rows, 0 code references — a deprecated
alias for vehicles) and DATA_IMPORT_BATCHES (86 rows, superseded by the
five document collections built across Tasks 6-15).
PocketBase refused the first attempt: it will not delete a collection
while any relation still points at it, and fuel_logs, plant_hours_log
and timesheet_entries all still carried a batch_reference relation to
data_import_batches that Task 7's migration made obsolete but never
removed. Confirmed live that nothing in src/ reads or writes that field
any more (only three stale type declarations in pb-types.ts), extended
the drop script to remove it from all three collections, re-ran, and the
deletion succeeded.
Cleaned up the now-dead DataImportBatchRecord interface and BatchType
union from pb-types.ts; BatchStatus survives as the shared type every
document/queue collection's processing_status now uses independently.
Final collection count: 17, down from a peak of 20 mid-restructure (16
original + 3 new document collections - PLANT - data_import_batches -
2 renames counted once each). Every remaining collection's fields match
what code actually reads and writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
qty_outstanding was written once at ingest as qty_outstanding: item.qty
and never decremented on receipt, so it permanently reported nothing
delivered. Replace every read/write of it with computeOutstanding()
(src/lib/po-fulfilment.ts), a pure sum over delivery_note_items that
cannot drift the way a stored counter can — it stays correct even
after OCR re-runs delete and recreate a note's items.
Also fixes src/app/dashboard/purchasing/[poId]/page.tsx, which computed
its own outstanding figure locally and clamped over-delivery to zero;
it now shares computeOutstanding and shows over-delivery as negative,
matching the rest of the app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
The three synchronous importers (timesheets, fuel, plant hours) used to
write status: "processed" on their audit row before a single insert ran,
and never updated it. Production had 21 such phantom-processed batches
across payroll and plant hours. resolveDocumentOutcome() computes the
real status from actual insert counts, so no route can claim success it
didn't earn.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.