Run-rate extrapolation, remaining budget and the under/on-track/over
ladder. The 'upcoming' status previously existed only on the list page
and now applies to both surfaces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Payroll, fuel, material and P&G aggregation as a pure function. Knows
nothing about time or PocketBase, so the projection and the agent tool
layer can both build on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sites list and site detail pages resolved a site's start date
differently, producing different projected profit for the same site:
list job_start_date -> LATEST fixed-cost date -> now
detail EARLIEST activity date, job_start_date ignored entirely
Different start date gives different elapsedMonths, which feeds
projectedTotal and projectedProfit directly. The detail page also read
from a global, unfiltered timesheet list, so another site's timesheet
could set this site's start date.
One implementation now: explicit job_start_date, else the earliest
site-filtered activity date, else now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parseDurationMonths was duplicated verbatim in the sites list and site
detail pages; isoWeekKey and monthKey were private to the detail page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RATE_PER_LITRE was hardcoded in four separate files, so a pump-price
change meant finding all four. It now lives here only, alongside the
working-day, weekly-capacity and month-length constants the site
financial roll-up needs.
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.
Mirrors ImportFuelLogsButton for the plant-hours weekly log upload
flow. Supports drag/drop, click-to-browse, shows import summary
(total / imported / mismatched / skipped), and links to the
exception-report CSV when rows are skipped.
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.
Two follow-up fixes from the Task 5 code-quality review:
- scripts/generate-plant-hours-template.ts uses
fileURLToPath(import.meta.url) instead of __dirname. The
project's tsconfig has module: esnext, so the moment the
package flips to ESM the original would throw
ReferenceError: __dirname is not defined.
- src/lib/plant-hours-template.ts documents that the frozen
header row is a best-effort request — SheetJS Community
Edition silently drops the !freeze property (only Pro emits
frozen-pane XML). The function still produces the canonical
13-column workbook, but the doc comment no longer claims a
behaviour that doesn't happen.
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>.
buildPlantHoursTemplateWorkbook() emits the canonical 13-column xlsx
with a frozen header row, typed cells (date/time/number), a sample
row using the supplied vehicle, and a Notes column widened for
readability. Used by both /api/plant-hours/template and the seed
script that writes RAW_DOCS/Plant_Hours_Template_2.xlsx.
Adds two tests that lock in behaviour the previous test file relied on
implicitly:
- validatePlantHoursRow rejects when the supplied vehicle's qr_code_id
doesn't match row.vehicle_qr (the guard the schema extends beyond
the original spec)
- computePlantHoursFields rounds value to 2 decimal places (a
fractional-input case the integer-only assertion didn't exercise)
Pure validator covering all the error/warning cases from the spec:
unknown QR, bad date range, end_hours < start_hours, bad time format,
missing cost (cell + vehicle), duplicate week, and the type-mismatch
warning. computePlantHoursFields() derives hours/value/over_budget/
hours_mismatch and the vehicle_unit_type_at_import snapshot.
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).
Moves normHeader/resolveColumn/parseDate/toNumber out of fuel-import-parse
into a new src/lib/import-parse.ts so plant-hours can reuse them. Adds
parseTime (HH:MM, 24h, single-digit hour normalised). Adds the
PLANT_COL_ALIASES table for the plant-hours route. fuel-import-parse
becomes a thin re-export shim — the fuel-logs route and its existing
test file are unchanged.
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.
Pure predicate: returns true when a vehicle has been dismissed and
no flagged log is strictly newer than the dismissal. Six Vitest cases
covering each branch and the auto-undismiss boundary.
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.