Workers becomes a lean roster (name, employee number, position, type,
site, rate) with filters unchanged. Everything currently cluttering the
list row -- total hours, cost, the 8-week sparkline, per-employee entry
history -- moves to a new worker detail page that doesn't exist yet
(today's "detail" is a dialog). Same pattern already used for Suppliers
and Vehicles/Equipment this session: lean list, detail page carries
depth.
Timesheets keeps its flat entries table but gains a genuinely separate
upload-status section -- the timesheets document collection already has
this data, it's just currently squeezed into a per-row badge instead of
being its own thing. The duplicated Import-timesheets button (currently
on both pages) moves here only.
QR badges: encodes employee_no directly, no new PocketBase field --
every worker already has a valid code the moment they exist, with no
generation step. Explicitly a deliberate tradeoff (guessable numbers,
no revocation) the user chose for now, pending seeing how the separate
timekeeping PWA wants identity handled -- moving to a stored opaque
token later is additive, not a rework. Card layout confirmed via the
visual companion (centered, QR-forward). Both single-worker and
batch-sheet print confirmed in scope. Rendering via the qrcode npm
package + the browser's own print dialog (@media print), not a PDF
library -- CSS handles real-world card dimensions more reliably than
fighting a PDF engine's coordinate system.
The timekeeping PWA itself is explicitly out of scope -- a separate
project this spec only needs to stay compatible with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 tasks across 6 phases: additive schema, pure matching/ledger
functions (TDD, tested in isolation), the one irreversible migration
(guarded, dry-run default), OCR wiring, API routes, and UI.
Grounded in the actual codebase rather than the spec's prose: the
existing "unknown supplier" inline-editor on the PO detail page
(SupplierEditor + POST /api/purchase-orders/[id]/supplier) is upgraded
in place rather than duplicated, following the same principle applied
to the sidebar restructure's delivery-note review queue. Every code
sample is written against files actually read during planning, not
guessed -- site-form-dialog.tsx, the /api/sites routes, matchSite,
po-fulfilment.ts, and the live purchase_orders.supplier data.
Self-review caught a real gap before committing: pb-bootstrap-schema.py
and pb-types.ts were named in the file-structure table but no task
actually touched them -- exactly the foot-gun docs/data-model.md
section 7 warns about. Added as Task 1 steps 5-8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19 -> ~16 was wrong. Ran the actual normalizeSupplierName() (trim +
whitespace-collapse + uppercase) against the three example pairs cited
as evidence: only the case-variant pair (Builders Warehouse) actually
collapses. The Ikhasi pair keeps a stray space after a hyphen that
differs between variants; the Global Precast pair differs by the
"(Pty) Ltd" suffix, not case or whitespace. Neither collapses under
exact-normalized matching.
This is not a defect -- it's the conservative-matching principle in
section 3 working as designed. The correct expectation against
production is 19 raw strings -> 18 groups, one merge. Both pairs that
don't collapse are exactly the kind of ambiguous case the spec already
says should stay separate for a human to resolve, not the kind that
should be forced together by loosening the normalizer.
Caught while grounding the implementation plan in real data, before
writing a single line of the plan itself.
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>
Reorganizes the flat 7-item sidebar into Workforce/Plant/Financial
groups, each with a landing dashboard and sub-pages split out of
today's combined Employees and Purchasing pages. Delivers the Delivery
Notes page and ingestion-status Uploads view that
2026-09-11-ingestion-restructure-design.md section 8 deliberately left
for later, now that the data model is settled.
Suppliers (new collection, PO relation, free-text-to-record matching
migration) is explicitly out of scope -- split into its own spec per
user request, same rigor as the ingestion restructure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.pyc files are generated, not source -- machine/version-specific,
produce spurious diffs, and a routine __pycache__ cleanup can delete
tracked files as a side effect (hit this earlier in the session). Added
__pycache__/ to .gitignore.
Also fixed a boundary error in the plan's checkbox sweep: Task 17's own
6 steps were skipped when checking off Task 18's range. Task 17 has been
done and independently verified since commit 29ee2cc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
timesheet_entries, fuel_logs and delivery_notes had no indexes at all --
fine at today's row counts, not once site count grows toward the 18-20
target. Added 8 indexes across fuel_logs, timesheet_entries,
delivery_notes, purchase_orders and plant_hours_log, unioned into each
collection's existing index array rather than replacing it.
Verified both pre-existing UNIQUE indexes (idx_po_number, load-bearing
for the placeholder-PO-number scheme; idx_plant_hours_log_qr_start,
prevents duplicate weekly imports) are byte-for-byte unchanged.
Idempotent: a second run touches nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tightened site from optional to required on fuel_logs, timesheet_entries,
delivery_notes and purchase_orders now that every route writes it and
every historical row has been backfilled. plant_hours_log was already
required (matches docs/data-model.md's documented invariant, confirmed
against production). timesheets (the document collection) was already
required from Task 6 -- a no-op here, not a regression.
fuel_sheets.site and plant_hours_sheets.site deliberately stay optional:
those sheets can legitimately span multiple sites.
Verified: creating a fuel_logs row with no site now gets a hard 400
(validation_required) from PocketBase itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 15 of the ingestion restructure. Edited the live n8n workflow
(005iI4jX5mq9DsTR) directly via MCP and published the new version.
Repointed Watchdog: List Stuck, Watchdog: Reset Stuck Rows, Fetch Pending
Batches, Atomic Claim, Claim Successful?, Download File and Escalate
Failure from data_import_batches/status onto purchase_orders/
processing_status. Verified zero in-flight rows would be orphaned: all
26 old purchase_orders-type batches were already processed or failed,
and all 24 real PO rows had processing_status unset (won't match the new
pending filter).
Two things found on the live workflow that the plan didn't anticipate:
- Atomic Claim had no ?filter=processing_status="pending" guard at all --
an unconditional PATCH, despite the workflow's own name and this doc's
prior description both calling it "atomic". Added while repointing the
same node.
- Build Callback Payload sent the extracted PO number as `po_number`.
process-batch's validator requires the field named exactly
`purchase_order_number` -- the old field name would have failed
validation on every single real upload, a total silent outage. Fixed
before publishing, verified against the route's actual validateExtracted().
Also removed the n8n-side duplicate short-circuit: the old
Build Callback Payload queried purchase_orders and PATCHed the batch row
directly from n8n code to pre-empt process-batch's own dedup logic.
process-batch's merge branch (Task 14) already does this correctly,
server-side, tested. Keeping both meant the same decision made twice in
two places that could disagree. Is Duplicate? node removed; Build
Callback Payload connects straight to Send Callback.
Published as the active version and verified by reading the workflow
back in full -- every repointed node confirmed, connections confirmed,
zero validation warnings.
Rewrote docs/n8n/purchase-order-ingest-implementation.md end to end to
describe the deployed shape rather than the retired one. Flagged (not
fixed, separate decision) a plaintext PB superuser password baked into
the Auth PB Admin node.
Marked Tasks 1-15 complete in the plan's own checkboxes.
Co-Authored-By: Claude Opus 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>
147 of 320 fuel_logs had no site relation; their only attribution was the
denormalised project_code text, which Task 18 deletes. Backfilled before
that column goes, resolving each row twice (by project_code and via the
vehicle's own site) and writing only on agreement.
147/147 written, 0 skipped, 0 remaining.
Recorded a caveat the dry run exposed: with one site in production both
resolvers draw from a candidate set of size one, so "both agree" is
guaranteed rather than evidence. The write is still the only possible
correct value, but the guard only bites once more sites exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found a transient 500 during download landed in the "no stored
file" bucket, so a broken run printed as a clean one and exited 0. Now
counted separately and exits non-zero.
Also: 404 is no longer swallowed on mutations (a PATCH against a deleted
record was counted as written), the target host and mode are printed, the
apply path reports a final written count, and filenames quote with
safe="".
Plan Task 1 Step 4 still carried the superseded copy-from-batch code
block under the rewritten prose, contradicting it. Replaced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Production disproved the copy-from-batch approach in two ways:
- 10 delivery-note batches carry no file_hash at all (they predate
78fa8da), and all 10 sit inside the 30-day dedup window, so copying
would have left a live gap rather than a historical one
- one PO row was produced from two different files with different
hashes via the dedup-by-PO-number branch, so copying gives
last-write-wins on an arbitrary one
Hashing the stored bytes has neither problem and does not depend on the
collection we are about to delete. 56/56 records covered, none skipped.
Immediately surfaced a real duplicate the old path missed: two delivery
notes numbered IN132202 with identical bytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 tasks across four phases, each phase ending at a shippable state.
Phase 1 additive only, Phase 2 renames and document collections, Phase 3
route cutover, Phase 4 drops. Nothing is deleted before Phase 4, so every
earlier phase is reversible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Partial deliveries against a PO line are the reason qty_outstanding is
dropped rather than wired: a stored counter must be decremented correctly
on every receipt, OCR re-run and correction, and PocketBase has no real
transactions to guarantee that. The sum over delivery_note_items cannot
drift.
Records the relation chain, the two invariants (unmatched lines count
toward nothing; over-delivery is legal), and the fulfilment test cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the data_import_batches upload pool with five typed document
collections that each own their file, makes upload status reflect what
actually happened, puts a real site on every ingested row, and removes
the project_code duplication.
Audited against production on 2026-09-11:
- 19 plant_hours batches marked 'processed'; plant_hours_log has 1 row
- 147 of 320 fuel_logs have no site relation
- purchase_orders.project_code holds requisition numbers and site names,
not project codes — automatic site matching succeeds ~8% of the time
- PLANT collection and two data_import_batches fields have zero readers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vehicles PB collection already has a unit_type field (fuel |
plant, default fuel, made optional on 2026-08-28) and the
PATCH /api/vehicles/[id] endpoint accepts it with full test
coverage. Today there is no UI surface for assigning or changing a
vehicle's unit_type on either the Fleet page (/dashboard/fuel) or
the Plant page (/dashboard/plant), so the 53 currently mis-classified
vehicles cannot be triaged from the UI.
This spec adds that surface via the existing inline-edit pattern
already used on both pages for description editing: a Type column
renders a 'Fleet (L)' / 'Plant (h)' badge in display mode and a
Select in edit mode. Saving a flip calls PATCH /api/vehicles/[id];
on success router.refresh() re-pulls the page and the row drops
out of the page's filter, effectively moving it to the other page.
Scope is intentionally tight:
- Server-side filter on each page (Fleet: unit_type=fuel or empty;
Plant: unit_type=plant AND cost_per_hour > 0).
- New Type column on the existing Fleet and Plant tables (no new
pages, no new top-level nav).
- New shared <UnitTypePill> / <UnitTypeSelect> cell components.
- Reuses the generic useInlineEdit<K extends keyof T> hook (no
hook changes needed).
- No schema migration, no API changes, no new tests.
Spec self-review pass: tightened two claims (pb-types.ts UnitType
line 37 not 48; useInlineEdit is already generic so no hook edit).
Updated the vehicles API to allow PATCH requests with an empty unit_type. This change ensures that existing records with unit_type='' can be updated without validation errors, while maintaining the default value of 'fuel' for new inserts. This adjustment resolves issues with updating pre-existing vehicle records that lack a unit_type field.
Adds a design doc for capturing weekly plant-equipment hours via an Excel
template against the existing /dashboard/plant page. One new
plant_hours_log collection, one vehicles.unit_type field, three new
routes (import, template download, exception report CSV), and an
extension to PlantView.
Spec covers: lenient import with per-row errors, downloadable exception
report for fix-and-retry, snapshot of vehicle.unit_type on each row,
over-budget flag against a per-week budget, and a symmetric attention
panel on /dashboard/plant for type mismatches.
Per-vehicle 'I've seen this' control on the Fuel dashboard. Stamps
attention_dismissed_at on the vehicles collection; auto-undismisses when
a flagged fuel log with date_logged > dismissal arrives. Timestamp only,
no reason field. Tiny POST/DELETE route + filter line in page mapper +
Dismiss button on alert rows + Dismissed/Restore pill on vehicle detail.
Co-Authored-By: Claude <noreply@anthropic.com>
5 tasks: API route (TDD), SupplierEditor component, wire into
PoDetailView with state, wrap received items in <details>,
final verification with Agent Browser.
Two scoped follow-ups to the supplier-unknown flag (9897f47):
- Edit supplier on detail page when flagged as unknown
- Collapse received items per delivery note by default
Captures the async-queue redesign of the PO/DN import pipeline (see
spec + plan) and rewrites the n8n implementation doc to match the
new cron-driven architecture (workflow 005iI4jX5mq9DsTR). Also
gitignores cooler.env so the local-only deployment reference is no
longer tracked.
Co-Authored-By: Claude <noreply@anthropic.com>