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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
Pure orchestration (importPlantHours) takes parsed source rows +
vehicle/site lookups and produces prepared plant_hours_log records
+ per-row errors. The route handles multipart upload, batch creation,
PB lookups, and insert-with-error-capture. Mismatched unit_type
vehicles are imported as audit-only records; PB dup-key failures
are recorded as 'duplicate' errors instead of failing the batch.
- Normalise date diff keys via parseDate()/slice() so imported rows
aren't all dumped as exceptions (date format mismatch silently
mis-classified every row as not_imported).
- Branch CSV vs XLSX when re-parsing the source file (type:'buffer'
doesn't work for CSV uploads).
- Validate batchId against /^[A-Za-z0-9]{15}$/ before interpolating
into the PB filter to close a filter-injection sink.
- Lift headers.indexOf out of the row loop, gate console.log, convert
dynamic imports to static, fix variable shadow.
buildPlantHoursExceptionCsv() emits a 15-column CSV with the 13
template columns + reason + operator_note, RFC-4180-quoted. The
GET /api/plant-hours/exception-report/[batchId] route re-parses the
source file from data_import_batches.original_file, diffs against
the imported plant_hours_log rows for that batch, and emits the CSV
of rows that did NOT import.
Emits the canonical xlsx with a sample row derived from the first
vehicle in PocketBase. Falls back to a hardcoded sample if the
vehicles collection is empty.
Threaded attentionDismissedAt from the page mapper into the view.
When set, the header shows a pill with the timestamp and a Restore
button that DELETEs /api/vehicles/{id}/attention-dismiss and
refreshes the page. Errors render inline below the pill.
Slug-to-prose map for restore failures mirrors the dismiss-side
pattern from commit c633681 — RESTORE_ERROR_COPY at module scope,
friendly copy in window-less inline error slot.
- Disable all dismiss buttons while any is in flight so rapid
clicks on a second vehicle don't silently no-op.
- Map API error slugs (bad_id, not_found, update_failed) to
user-facing copy so window.alert shows prose, not machine tags.
- Add aria-busy to the dismiss buttons.
- Drop redundant e.stopPropagation(); preventDefault() is
sufficient to suppress the wrapping <Link>.
Click on an AlertCard or AlertListRow to hide the vehicle from
'Attention required' instantly. POST /api/vehicles/{id}/attention-dismiss
stamps the field; optimistic state mirrors that locally so the row
disappears before the network roundtrip. Error path reverts the
overlay and shows window.alert (same pattern as inline-edit).
Server mapper now emits attentionDismissedAt and maxFlaggedDate on
each VehicleSummary. The flaggedVehicles memo uses the new helper
to hide vehicles whose current flagged logs are all at-or-before
the user's most recent dismissal. Auto-undismiss is implicit: a
new fuel log with date_logged > attentionDismissedAt brings the
vehicle back on the next page load.
Sets or clears vehicles.attention_dismissed_at via pbAdmin().
15-char PB id pattern, 404 when PB reports the record is missing,
500 on any other write failure. Test mocks pbAdmin() so no live PB
needed.
Wrap the received-items list in <details>/<summary>. Default
collapsed; click to expand. Hidden when dn.receivedItems is
empty (preserved). The 'Raw OCR data' <details> lower in the
same card is independent and unaffected.
PoDetailView now mirrors its detail prop into local state and
passes a handleSupplierSaved callback that updates state.supplier
and triggers the existing refresh(). SupplierEditor replaces the
static Badge when the supplier is unknown.
Badge-as-button + Input + Save/Cancel client component. Only
renders when isUnknownSupplier(currentSupplier) is true. Save
POSTs to /api/purchase-orders/{poId}/supplier and invokes
onSaved(next). Esc cancels; Enter saves when valid.
Not yet rendered — Task 3 wires it into PoDetailView.
Validates { supplier } (trimmed, 1-200 chars) and PATCHes the
purchase_orders.supplier field via pbAdmin(). 400/404/500 with
explicit error codes; mirrors the retry-route pattern.
Updated the PurchasingPage component to include received items data, allowing for accurate fulfillment percentage calculations based on authoritative data. The implementation now sums received quantities per purchase order item, replacing the previous denormalized field. Additionally, the needs-review delivery notes component has been improved with a modal for reviewing slips and assigning purchase orders, enhancing user interaction. The SlipPreview component has been integrated for better visual representation of delivery slips.
Updated the OCR result processing to include validation of matched PO items before determining the verification tier. This change ensures that if the local PO items fetch is empty but n8n supplied matched IDs, the tier will downgrade to draft, requiring user review. Additionally, the `resolveOcrTier` function has been modified to account for item verifiability, improving the accuracy of tier assignments based on confidence levels and PO resolution status. Unit tests have been added to cover these new scenarios.
Updated the OCR result processing to include a more robust PO matching mechanism. The new implementation prioritizes exact matches, followed by normalized comparisons, and finally employs a Levenshtein-based fuzzy match for improved accuracy. This change ensures that delivery notes are correctly linked to purchase orders, even when OCR introduces minor errors. Additionally, the behavior for handling confidence levels and PO resolution has been clarified in the documentation, enhancing overall processing reliability.
OCR is noisy. Instead of exact-then-fuzzy-fallback, always fetch
all ~15 POs from PocketBase, normalise both sides (strip non-
alphanumeric, lowercase), score each with Levenshtein similarity,
and return the best match above 0.75 threshold. Single-char
insert/delete (MEGCP09239 vs MEGCPO9239) scores ~0.9 — matches.
Completely different POs (similarity <0.5) don't match.
Plus the existing 'return 200 with empty items' fallback if no
match — keeps the workflow loop alive even when OCR is bad.